Рискованные операции в приложениях требуют особого внимания. Допустим, вы хотите пересчитать пени, отправить уведомление о штрафных санкциях или расторгнуть договор – все эти действия могут негативно сказаться на бизнесе, если выполнены некорректно. В таких случаях паттерн 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 предоставляет мощное средство контроля для критичных бизнес-операций. Детальное разделение логики на шаги помогает не только повысить безопасность, но и делает систему гибкой и расширяемой. Независимо от того, используете ли вы его для пересчета пеней или других опасных действий, данный паттерн помогает обеспечить контроль, прозрачность и удобство аудита, что в итоге положительно влияет на репутацию и надежность вашего бизнеса.