Implementing the Approval Workflow pattern in Laravel

Implementing the Approval Workflow pattern in Laravel

Risky operations in applications require special attention. Let's say you want to recalculate penalties, send a penalty notice, or terminate a contract - all of these actions can negatively impact your business if performed incorrectly. In such cases, the Approval Workflow pattern allows you to first capture the transaction for later administrator approval, and then securely execute it. In this article we will look at a step-by-step algorithm for implementing this pattern in Laravel, its advantages, disadvantages, possible use cases and areas for further improvement.

Step 1: Create migrations and models

The first thing you need to do is to provide data storage for all approval requests. To do this, create the necessary database migration that will describe a table for storing transaction parameters (e.g. id, transaction type, user id, payload, status, signature, etc.).

<?php

declare(strict_types=1);

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('approvals', function (Blueprint $table): void {
            $table->id();
            $table->string('operation_type');
            $table->foreignId('user_id');
            $table->string('data_class')->nullable();
            $table->json('payload')->nullable();
            $table->string('status')->default(\App\Enums\ApprovalStatusEnum::PENDING);
            $table->foreignId('approved_by')->nullable()->constrained(
                table: 'users'
            );
            $table->timestamp('approved_at')->nullable();
            $table->timestamps();
            $table->string('payload_signature')->nullable();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('approvals');
    }
};

 

Once the migrations have been created, the Approval model should be defined. The model should include the necessary relationships (e.g., with users) and casts for data transformation. This approach will simplify data handling and provide flexibility for handling different types of operations.

<?php

declare(strict_types=1);

namespace App\Models;

use App\Actions\Approvals\CoreApproval;
use App\Enums\ApprovalStatusEnum;
use Carbon\Carbon;
use Database\Factories\ApprovalFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\LaravelData\Data;

/**
 * @property CoreApproval<Data> $operation_type
 * @property ApprovalStatusEnum $status
 * @property Carbon|null $approved_at
 * @property Data|null $data_class
 */
class Approval extends Model
{
    /** @use HasFactory<ApprovalFactory> */
    use HasFactory;

    protected $fillable = [
        'operation_type',
        'user_id',
        'data_class',
        'payload',
        'status',
        'approved_by',
        'approved_at',
        'payload_signature',
    ];

    /**
     * @return BelongsTo<User, $this>
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /**
     * @return BelongsTo<User, $this>
     */
    public function approvedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'approved_by');
    }

    protected function casts(): array
    {
        return [
            'payload' => 'array',
            'approved_at' => 'timestamp',
            'status' => ApprovalStatusEnum::class,
        ];
    }

    /**
     * @return Attribute<string, string>
     */
    protected function label(): Attribute
    {
        return Attribute::get(
            get: fn ($value, array $attributes) => $this->getApprovalClass()->getLabel(),
        );
    }

    /**
     * @return Attribute<string, string>
     */
    protected function link(): Attribute
    {
        return Attribute::get(
            get: fn ($value, array $attributes) => $this->getApprovalClass()->getLink(),
        );
    }

    /**
     * @return Attribute<string, string>
     */
    protected function related(): Attribute
    {
        return Attribute::get(
            get: fn ($value, array $attributes) => $this->getApprovalClass()->getRelatedLabel(),
        );
    }

    /**
     * @return Attribute<string, string>
     */
    protected function description(): Attribute
    {
        return Attribute::get(
            get: fn ($value, array $attributes) => $this->getApprovalClass()->getDescription(),
        );
    }

    /**
     * @return CoreApproval<Data>
     */
    protected function getApprovalClass(): CoreApproval
    {
        $class = $this->operation_type;

        return new $class($this->data_class ? $this->data_class::from($this->payload) : null);
    }
}

 

Step 2: Defining the allowed statuses and forming enum

To improve readability and security, it is worth defining a set of statuses that an operation can accept. Using enums in Laravel allows you to clearly describe the possible statuses: pending, approved, and rejected.

<?php

declare(strict_types=1);

namespace App\Enums;

use App\Parent\Filament\EnumToFilament;

enum ApprovalStatusEnum: string
{
    use EnumToFilament;

    case PENDING = 'pending';

    case APPROVED = 'approved';

    case REJECTED = 'rejected';
}

 

Step 3: Create an abstract CoreApproval class

The CoreApproval abstract class plays a key role - it defines the interface for specific confirmation classes. In addition to the handle method, which will execute the operation itself, this class provides information display (getLabel, getLink, getDescription and getRelatedLabel methods) for easy visualization in the admin panel. Using DTO (Data Transfer Object) in this approach allows you to work with data in a more structured way.

<?php

declare(strict_types=1);

namespace App\Actions\Approvals;

use Spatie\LaravelData\Data;

/**
 * @template T of Data|null
 */
abstract class CoreApproval
{
    /**
     * @param  T|null  $data
     */
    public function __construct(
        protected ?Data $data,
    ) {}

    public function getLabel(): string
    {
        return $this->data ? $this->data::class : '';
    }

    public function getDescription(): string
    {
        return '';
    }

    public function getLink(): string
    {
        return '#';
    }

    public function getRelatedLabel(): string
    {
        return 'app';
    }

    abstract public function handle(): void;
}

 

Step 4: Register Approval Tasks (Register Approval)

When you need to approve an operation, you should create an entry in the approvals table. It is important to avoid duplication: a signature (payload_signature) is generated for each request to avoid re-creating an identical operation. This step ensures data integrity and protects against potential errors.

<?php

declare(strict_types=1);

namespace App\Actions\Approvals;

use App\Data\Approvals\ApprovalData;
use App\Enums\ApprovalStatusEnum;
use App\Models\Approval;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\LaravelData\Data;

/**
 * @template T of Data|null
 */
final class RegisterApproval
{
    use AsAction;

    /**
     * @param  ApprovalData<Data|null>  $approvalData
     */
    public function handle(ApprovalData $approvalData): void
    {
        if ($this->isAlreadyExists($approvalData)) {
            return;
        }
        $data = $approvalData->toArray();
        $data['payload_signature'] = $this->generateSignature($approvalData->payload);
        $approvalModel = Approval::create($data);
        $approvalData->id = $approvalModel->id;
    }

    /**
     * @param  ApprovalData<Data|null>  $approvalData
     */
    protected function isAlreadyExists(ApprovalData $approvalData): bool
    {
        return Approval::where('data_class', $approvalData->data_class)->where('payload_signature', $this->generateSignature($approvalData->payload))->whereIn('status', [ApprovalStatusEnum::PENDING, ApprovalStatusEnum::REJECTED])->exists();
    }

    /**
     * @param  array<string, mixed>|null  $payload
     */
    protected function generateSignature(?array $payload): ?string
    {
        // @phpstan-ignore-next-line
        return $payload ? md5(json_encode($payload)) : null;
    }
}

 

Step 5: Concrete implementation of the statement (example with penalty calculation)

Separate classes inherited from CoreApproval are created for each specific operation. Here is an example of implementation for working with penalties - if there are overdue payments, the system does not immediately recalculate penalties, but sends a request for approval to the administrator. This class additionally defines methods responsible for displaying information in the admin panel and for performing a specific operation.

<?php

declare(strict_types=1);

namespace App\Actions\Approvals;

use App\Actions\Invoices\CreateMahnung;
use App\Data\Approvals\MahnungApprovalData;

/**
 * @extends CoreApproval<MahnungApprovalData>
 */
final class MahnungApproval extends CoreApproval
{
    public function handle(): void
    {
        if (! $this->data) {
            return;
        }
        app(CreateMahnung::class)->handle($this->data->invoiceData, $this->data->level);
    }

    #[\Override]
    public function getLabel(): string
    {
        if (! $this->data) {
            return '';
        }

        return 'Eine Mahnung für Rechnung #' . $this->data->invoiceData->id . ' ist noch nicht verschickt worden.';
    }

    #[\Override]
    public function getRelatedLabel(): string
    {
        if (! $this->data) {
            return '';
        }

        return 'Team #' . $this->data->invoiceData->team_id;
    }

    #[\Override]
    public function getLink(): string
    {
        if (! $this->data) {
            return '#';
        }

        return route('filament.admin.resources.teams.edit', ['record' => $this->data->invoiceData->team_id]);
    }

    #[\Override]
    public function getDescription(): string
    {
        if (! $this->data) {
            return '';
        }

        return 'Kunden ' . $this->data->invoiceData->team?->name;
    }
}

 

Step 6: The process of rejecting and approving transactions

The administrator in the admin panel can view the list of requests and decide whether to approve or reject the transaction, depending on the situation. In case of approval, the handle method from the CoreApproval class is called, which directly performs the action. In case of rejection, the status of the request is changed, the time and the user who performed the action are recorded.

<?php

declare(strict_types=1);

namespace App\Actions\Approvals;

use App\Enums\ApprovalStatusEnum;
use App\Exceptions\AlreadyApprovedException;
use App\Models\Approval;
use Lorisleiva\Actions\Concerns\AsAction;

final class RejectApproval
{
    use AsAction;

    public function handle(int $approvalId, int $approvedBy, bool $forceReject = false): void
    {
        $approval = Approval::findOrFail($approvalId);
        if ($approval->status === ApprovalStatusEnum::APPROVED && $forceReject) {
            throw new AlreadyApprovedException('Can not reject already approval operation');
        }
        $approval->status = ApprovalStatusEnum::REJECTED;
        $approval->approved_at = now();
        $approval->approved_by = $approvedBy;
        $approval->save();
    }
}

We just change the status, we don't run any.

If the administrator decides to approve the task, we need to run a method of our abstract class:

<?php

declare(strict_types=1);

namespace App\Actions\Approvals;

use App\Enums\ApprovalStatusEnum;
use App\Exceptions\AlreadyApprovedException;
use App\Models\Approval;
use Carbon\Carbon;
use Lorisleiva\Actions\Concerns\AsAction;

final class ApproveApproval
{
    use AsAction;

    public function handle(int $approvalId, int $approvedBy): void
    {
        $approvalModel = Approval::findOrFail($approvalId);
        if ($approvalModel->status === ApprovalStatusEnum::APPROVED) {
            throw new AlreadyApprovedException('Can not approve second time');
        }
        $approvalModel->status = ApprovalStatusEnum::APPROVED;
        $approvalModel->approved_by = $approvedBy;
        $approvalModel->approved_at = Carbon::now();
        $class = $approvalModel->operation_type;
        (new $class($approvalModel->data_class ? $approvalModel->data_class::from($approvalModel->payload) : null))->handle();
        $approvalModel->save();
    }
}

 

Here's what the Filament table will look like where you can manage tasks:

 

<?php

declare(strict_types=1);

namespace App\Filament\Resources;

use App\Actions\Approvals\ApproveApproval;
use App\Actions\Approvals\RejectApproval;
use App\Enums\ApprovalStatusEnum;
use App\Filament\Resources\ApprovalResource\Pages;
use App\Models\Approval;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;

final class ApprovalResource extends Resource
{
    protected static ?string $model = Approval::class;

    protected static ?string $slug = 'approvals';

    protected static ?string $navigationIcon = 'heroicon-o-arrow-down-on-square';

    #[\Override]
    public static function getNavigationLabel(): string
    {
        return __('approvals.Approval');
    }

    #[\Override]
    public static function getLabel(): string
    {
        return __('approvals.Approval');
    }

    #[\Override]
    public static function getPluralModelLabel(): string
    {
        return __('approvals.Approvals');
    }

    #[\Override]
    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                TextInput::make('operation_type')
                    ->required(),

                Select::make('user_id')
                    ->relationship('user', 'name')
                    ->searchable()
                    ->required(),

                TextInput::make('data_class'),

                TextInput::make('status')
                    ->required(),

                Select::make('approved_by')
                    ->relationship('approvedBy', 'name')
                    ->searchable(),

                DatePicker::make('approved_at')
                    ->label('Approved Date'),

                Placeholder::make('created_at')
                    ->label('Created Date')
                    ->content(fn (?Approval $record): string => $record?->created_at?->diffForHumans() ?? '-'),

                Placeholder::make('updated_at')
                    ->label('Last Modified Date')
                    ->content(fn (?Approval $record): string => $record?->updated_at?->diffForHumans() ?? '-'),
            ]);
    }

    #[\Override]
    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('id')->label(trans('approvals.id')),

                TextColumn::make('operation_type')->formatStateUsing(fn (string $state, Approval $record): HtmlString => new HtmlString($record->label))->description(fn (string $state, Approval $record): string => $record->description)->label(trans('approvals.operation_type')),

                TextColumn::make('user.name')
                    ->searchable()
                    ->sortable(),

                TextColumn::make('status')->formatStateUsing(fn (ApprovalStatusEnum $state): HtmlString => new HtmlString(trans('approvals.' . $state->value))),

                TextColumn::make('approvedBy.name')
                    ->searchable()
                    ->sortable()->label(trans('approvals.approved_by')),

                TextColumn::make('approved_at')
                    ->label(trans('approvals.approved_at'))
                    ->date(),
                TextColumn::make('created_at')
                    ->label(trans('approvals.created_at'))
                    ->date(),
            ])
            ->filters([
                //
            ])
            ->actions([
                Action::make('approve')
                    ->label(trans('approvals.Approve'))
                    ->action(fn (Approval $record) => app(ApproveApproval::class)->handle($record->id, (int) Auth::id()))
                    ->color('success')
                    ->sendSuccessNotification()
                    ->hidden(fn (Approval $record) => $record->status === ApprovalStatusEnum::APPROVED)
                    ->icon('heroicon-o-check'),
                Action::make('decline')
                    ->label(trans('approvals.Decline'))
                    ->action(fn (Approval $record) => app(RejectApproval::class)->handle($record->id, (int) Auth::id()))
                    ->color('danger')
                    ->hidden(fn (Approval $record) => $record->status !== ApprovalStatusEnum::PENDING)
                    ->sendSuccessNotification()
                    ->icon('heroicon-o-no-symbol'),
                Action::make('view_link')
                    ->label(fn (Approval $record) => $record->related)
                    ->url(fn (Approval $record) => $record->link)->icon('heroicon-o-briefcase'),
                DeleteAction::make(),
            ])
            ->bulkActions([
                BulkActionGroup::make([
                    DeleteBulkAction::make(),
                ]),
            ]);
    }

    #[\Override]
    public static function getPages(): array
    {
        return [
            'index' => Pages\ListApprovals::route('/'),
            'edit' => Pages\EditApproval::route('/{record}/edit'),
        ];
    }

    /**
     * @return Builder<Approval>
     */
    #[\Override]
    public static function getGlobalSearchEloquentQuery(): Builder
    {
        return parent::getGlobalSearchEloquentQuery()->with(['user', 'approvedBy']);
    }

    #[\Override]
    public static function getGloballySearchableAttributes(): array
    {
        return ['user.name', 'approvedBy.name'];
    }

    /**
     * @param  Approval  $record
     * @return string[]
     */
    #[\Override]
    public static function getGlobalSearchResultDetails(Model $record): array
    {
        $details = [];

        if ($record->user) {
            $details['User'] = $record->user->name;
        }

        if ($record->approvedBy) {
            $details['ApprovedBy'] = $record->approvedBy->name;
        }

        return $details;
    }

    #[\Override]
    public static function getNavigationGroup(): string
    {
        return __(config_string('filament-spatie-roles-permissions.navigation_section_group', 'filament-spatie-roles-permissions::filament-spatie.section.roles_and_permissions'));
    }
}

 

Advantages and disadvantages of the solution

Advantages:

1. Security - risky transactions are subject to additional scrutiny.

2. Flexibility - you can easily add new types of operations by implementing the corresponding class inherited from CoreApproval.

3. Logging and auditing - capturing statuses and changes allows you to analyze the history of operations.

4. integration with admin panel - the ability to work visually through the interface reduces the complexity of management.

Disadvantages:

1. Additional complexity - the approval system requires the creation of additional models and classes, which can complicate the architecture.

2. Delays in the execution of operations - manual approval can slow down the execution process, which is unacceptable in some cases.

3. Possible data synchronization problems if applications are processed in parallel.

 

Use Cases Applications

The Approval Workflow pattern is suitable for all cases where the consequences of a transaction execution are critical:

- Financial transactions (recalculation of penalties, making payments).

- Change of contractual relations (termination of contracts).

- Launching critical business processes that require validation.

- Any functionality whose execution should be controlled by the administrator.

Possibilities for further improvement

1. Extension of admin panel functionality - integration with modern UI-tools (e.g. Filament) for convenient management of requests.

2. Integration with external notification systems - sending notifications via email or messengers.

3. Introduction of more sophisticated logic for checking duplicate requests and mechanisms for automatic approval in case of inactivity.

4. Expansion of audit capabilities - storing change history and automatic report generation.

Areas of application

This pattern can be applied to any systems where control over the execution of risky transactions is required. This could be the financial sector, contract management systems, accounting and stores where risky security or price changes require additional verification.

Don't forget the tests. This is what a mocks-aware test for phpunit might look like:

<?php

declare(strict_types=1);

namespace Tests\Feature\Actions\Approvals;

use App\Actions\Approvals\ApproveApproval;
use App\Actions\Approvals\MockApproval;
use App\Enums\ApprovalStatusEnum;
use App\Jobs\InvoiceCancelledQueueJob;
use App\Models\Approval;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;

final class ApproveApprovalTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_approves_model_and_executes_class(): void
    {
        Bus::fake();
        $approval = Approval::factory()->createOne([
            'operation_type' => MockApproval::class,
            'data_class' => null,
            'status' => ApprovalStatusEnum::PENDING,
            'approved_at' => null,
        ]);
        $user = User::factory()->createOne();

        app(ApproveApproval::class)->handle($approval->id, $user->id);
        $approval->refresh();

        Bus::assertDispatched(InvoiceCancelledQueueJob::class);
        $this->assertEquals(ApprovalStatusEnum::APPROVED->value, $approval->status->value);
        $this->assertNotNull($approval->approved_at);
    }
}

 

And the test of the enrollment process will look like this:

<?php

declare(strict_types=1);

namespace Tests\Feature\Actions\Approvals;

use App\Actions\Approvals\MockApproval;
use App\Actions\Approvals\RegisterApproval;
use App\Data\Approvals\ApprovalData;
use App\Data\InvoiceData;
use App\Enums\ApprovalStatusEnum;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;

#[CoversClass(RegisterApproval::class)]
final class RegisterApprovalTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_creates_new_approval(): void
    {
        $user = User::factory()->createOne();
        // @phpstan-ignore-next-line
        app(RegisterApproval::class)->handle(new ApprovalData(
            operation_type: MockApproval::class,
            user_id: $user->id,
            status: ApprovalStatusEnum::PENDING,
            approved_by: null,
            approved_at: null,
        ));

        $this->assertDatabaseHas('approvals', ['operation_type' => MockApproval::class]);
    }

    public function test_it_does_not_creates_duplicates(): void
    {
        $user = User::factory()->createOne();
        $invoice = Invoice::factory()->createOne();
        // @phpstan-ignore-next-line
        app(RegisterApproval::class)->handle(new ApprovalData(
            operation_type: MockApproval::class,
            user_id: $user->id,
            payload: InvoiceData::fromModel($invoice)->toArray(),
            status: ApprovalStatusEnum::PENDING,
            approved_by: null,
            approved_at: null,
        ));
        // @phpstan-ignore-next-line
        app(RegisterApproval::class)->handle(new ApprovalData(
            operation_type: MockApproval::class,
            user_id: $user->id,
            payload: InvoiceData::fromModel($invoice)->toArray(),
            status: ApprovalStatusEnum::PENDING,
            approved_by: null,
            approved_at: null,
        ));

        $this->assertDatabaseCount('approvals', 1);
    }
}

 

Conclusion

Implementing the Approval Workflow pattern in Laravel provides a powerful control tool for critical business operations. The detailed separation of logic into steps not only helps to improve safety, but also makes the system flexible and extensible. Whether you use it to recalculate penalties or other dangerous activities, this pattern helps provide control, transparency, and auditability, which ultimately has a positive impact on the reputation and reliability of your business.

 

Popular Posts

My most popular posts

Maximum productivity on remote job
Business

Maximum productivity on remote job

I started my own business and intentionally did my best to work from anywhere in the world. Sometimes I sit with my office with a large 27-inch monitor in my apartment in Cheboksary. Sometimes I’m in the office or in some cafe in another city.

Hello! I am Sergey Emelyanov and I am hardworker
Business PHP

Hello! I am Sergey Emelyanov and I am hardworker

I am a programmer. I am an entrepreneur in my heart. I started making money from the age of 11, in the harsh 90s, handing over glassware to a local store and exchanging it for sweets. I earned so much that was enough for various snacks.

Hire Professional CRM developer for $25 per hour

I will make time for your project. Knowledge of Vtiger CRM, SuiteCRM, Laravel, Vue.js, Wordpress. I offer cooperation options that will help you take advantage of external experience, optimize costs and reduce risks. Full transparency of all stages of work and accounting for time costs. Pay only development working hours after accepting the task. Accept PayPal and Payoneer payment systems. How to hire professional developer? Just fill in the form

Telegram
@sergeyem
Telephone
+4915211100235
Email