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.