Реализация паттерна Approval Workflow в Laravel

Реализация паттерна Approval Workflow в Laravel

Рискованные операции в приложениях требуют особого внимания. Допустим, вы хотите пересчитать пени, отправить уведомление о штрафных санкциях или расторгнуть договор – все эти действия могут негативно сказаться на бизнесе, если выполнены некорректно. В таких случаях паттерн Approval Workflow позволяет сначала фиксировать операцию для последующего утверждения администратором, а затем уже её безопасное исполнение. В данной статье мы рассмотрим пошаговый алгоритм реализации данного паттерна в Laravel, его преимущества, недостатки, возможные use cases и направления для дальнейшего усовершенствования.

Шаг 1. Создание миграций и моделей

Первым делом необходимо обеспечить хранение данных для всех заявок на утверждение. Для этого создайте необходимую миграцию базы данных, которая будет описывать таблицу для хранения параметров операций (например, id, тип операции, идентификатор пользователя, payload, статус, подпись и т.д.).

<?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');
    }
};

 

После создания миграций следует определить модель Approval. Модель должна включать необходимые связи (например, с пользователями) и касты для преобразования данных. Такой подход упростит работу с данными и обеспечит гибкость при работе с различными типами операций.

<?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);
    }
}

 

Шаг 2. Определение допустимых статусов и формирование enum

Для повышения читаемости и безопасности стоит определить набор статусов, которые может принимать операция. Использование перечислений (enum) в Laravel позволяет ясно описать возможные состояния: ожидание (pending), одобрено (approved) и отклонено (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';
}

 

Шаг 3. Создание абстрактного класса CoreApproval

Абстрактный класс CoreApproval играет ключевую роль – он определяет интерфейс для конкретных классов подтверждения. Кроме метода handle, который будет исполнять саму операцию, данный класс обеспечивает отображение информации (методы getLabel, getLink, getDescription и getRelatedLabel) для удобства визуализации в админ-панели. Использование DTO (Data Transfer Object) в данном подходе позволяет работать с данными более структурированно.

<?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;
}

 

Шаг 4. Регистрация задач на утверждение (Register Approval)

Когда возникает необходимость подтвердить операцию, следует создать запись в таблице approvals. Здесь важно не допустить дублирования: для каждого запроса генерируется подпись (payload_signature), позволяющая избегать повторного создания идентичной операции. Этот шаг обеспечивает целостность данных и защиту от потенциальных ошибок.

<?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;
    }
}

 

Шаг 5. Конкретная имплементация утверждения (пример с расчетом пеней)

Для каждой конкретной операции создаются отдельные классы, наследуемые от CoreApproval. Приведем пример реализации для работы с пени – при наличии просроченных платежей система не сразу пересчитывает штрафы, а отправляет запрос на утверждение администратору. В данном классе дополнительно задаются методы, отвечающие за отображение информации в админ-панели и за выполение конкретной операции.

<?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;
    }
}

 

Шаг 6. Процесс отклонения и одобрения операций

Администратор в админ-панели может просмотреть список заявок и, в зависимости от ситуации, принять решение об одобрении или отклонении операции. В случае одобрения вызывается метод handle из класса CoreApproval, который непосредственно выполняет действие. При отклонении меняется статус заявки, фиксируется время и пользователь, совершивший действие.

<?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();
    }
}

Мы просто меняем статус, запуск никакой у нас не производится.

Если администратор решил одобрить задачу, то нам нужно запустить метод нашего абстрактного класса:

<?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();
    }
}

 

Вот как будет выглядеть таблица Filament, где можно управлять задачами:

 

<?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'));
    }
}

 

Преимущества и недостатки решения

Преимущества:

1. Безопасность – рискованные операции проходят дополнительную проверку.

2. Гибкость – можно легко добавлять новые типы операций, реализуя соответствующий класс, наследуемый от CoreApproval.

3. Логирование и аудит – фиксирование статусов и изменений позволяет анализировать историю операций.

4. Интеграция с админ-панелью – возможность визуальной работы через интерфейс снижает сложность управления.

Недостатки:

1. Дополнительная сложность – система утверждения требует создания дополнительных моделей и классов, что может усложнить архитектуру.

2. Задержки в исполнении операций – ручное утверждение может замедлить процесс выполнения, что в некоторых случаях неприемлемо.

3. Возможные проблемы с синхронизацией данных, если заявки обрабатываются параллельно.

 

Use Cases применения

Паттерн Approval Workflow подходит для всех случаев, когда последствия выполнения операции критичны:

• Финансовые операции (пересчет пеней, проведение платежей).

• Изменение договорных отношений (расторжение договоров).

• Запуск важных бизнес-процессов, требующих проверки.

• Любая функциональность, выполнение которой должно контролироваться администратором.

 

Возможности дальнейшего усовершенствования

1. Расширение функционала админ-панели – интеграция с современными UI-инструментами (например, Filament) для удобного управления заявками.

2. Интеграция с внешними системами уведомлений – отправка оповещений через email или мессенджеры.

3. Введение более сложной логики проверки дублирования заявок и механизмов автоматического утверждения в случае отсутствия активности.

4. Расширение возможности аудита – хранение истории изменений и автоматическая генерация отчетов.

Области применения

Данный паттерн может применяться в любых системах, где требуется контроль за выполнением рискованных операций. Это может быть финансовый сектор, системы управления договорами, бухгалтерский учет и магазины, где рисковые изменения безопасности или цены требуют дополнительной проверки.

 

Не забываем про тесты. Вот так может выглядеть тест с учётом моков для phpunit:

<?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);
    }
}

 

А тест процесса регистрации будет выглядеть так:

<?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);
    }
}

 

Заключение

Реализация паттерна Approval Workflow на Laravel предоставляет мощное средство контроля для критичных бизнес-операций. Детальное разделение логики на шаги помогает не только повысить безопасность, но и делает систему гибкой и расширяемой. Независимо от того, используете ли вы его для пересчета пеней или других опасных действий, данный паттерн помогает обеспечить контроль, прозрачность и удобство аудита, что в итоге положительно влияет на репутацию и надежность вашего бизнеса.

 

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

Я запустил собственный бизнес и намеренно сделал всё возможное, чтобы работать из любой точки мира. Иногда я сижу с своём кабинете с большим 27-дюймовым монитором в своей квартире в г. Чебоксары. Иногда я нахожусь в офисе или в каком-нибудь кафе в другом городе.

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js, Wordpress. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235