©Sergey Emelyanov 2025 | Alle Rechte vorbehalten
Riskante Vorgänge in Anwendungen erfordern besondere Aufmerksamkeit. Nehmen wir an, Sie möchten Strafen neu berechnen, einen Strafbescheid versenden oder einen Vertrag kündigen - alle diese Aktionen können sich negativ auf Ihr Unternehmen auswirken, wenn sie falsch ausgeführt werden. In solchen Fällen können Sie mit dem Genehmigungs-Workflow-Muster die Transaktion zunächst für eine spätere Genehmigung durch den Administrator erfassen und sie dann sicher ausführen. In diesem Artikel werden wir uns einen Schritt-für-Schritt-Algorithmus für die Implementierung dieses Musters in Laravel, seine Vorteile, Nachteile, mögliche Anwendungsfälle und Bereiche für weitere Verbesserungen ansehen.
Als Erstes müssen Sie die Datenspeicherung für alle Genehmigungsanträge bereitstellen. Dazu erstellen Sie die erforderliche Datenbankmigration, die eine Tabelle zur Speicherung von Transaktionsparametern (z. B. ID, Transaktionsart, Benutzer-ID, Nutzlast, Status, Signatur usw.) beschreibt.
<?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');
}
};
Sobald die Migrationen erstellt sind, sollte das Genehmigungsmodell definiert werden. Das Modell sollte die erforderlichen Beziehungen (z. B. zu Benutzern) und Abdrücke für die Datenumwandlung enthalten. Dieser Ansatz vereinfacht die Datenverarbeitung und bietet Flexibilität für verschiedene Arten von Vorgängen.
<?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);
}
}
Um die Lesbarkeit und Sicherheit zu verbessern, lohnt es sich, eine Reihe von Status zu definieren, die ein Vorgang annehmen kann. Die Verwendung von Enums in Laravel ermöglicht es Ihnen, die möglichen Status klar zu beschreiben: ausstehend, genehmigt und abgelehnt.
<?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';
}
Die abstrakte Klasse CoreApproval spielt eine Schlüsselrolle - sie definiert die Schnittstelle für spezifische Bestätigungsklassen. Neben der Handle-Methode, die die Operation selbst ausführt, bietet diese Klasse Anzeigeinformationen (getLabel-, getLink-, getDescription- und getRelatedLabel-Methoden) zur einfachen Visualisierung im Admin-Panel. Die Verwendung von DTO (Data Transfer Object) in diesem Ansatz ermöglicht eine strukturiertere Arbeit mit Daten.
<?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;
}
Wenn Sie einen Vorgang genehmigen müssen, sollten Sie einen Eintrag in der Genehmigungstabelle erstellen. Dabei ist es wichtig, Doppelarbeit zu vermeiden: Für jeden Antrag wird eine Signatur (payload_signature) erstellt, um zu vermeiden, dass ein identischer Vorgang erneut angelegt wird. Dieser Schritt gewährleistet die Datenintegrität und schützt vor möglichen Fehlern.
<?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;
}
}
Für jeden spezifischen Vorgang werden separate, von CoreApproval geerbte Klassen erstellt. Hier ein Beispiel für eine Implementierung für die Arbeit mit Bußgeldern - wenn es überfällige Zahlungen gibt, berechnet das System die Bußgelder nicht sofort neu, sondern sendet eine Anfrage zur Genehmigung an den Administrator. Diese Klasse definiert zusätzlich Methoden, die für die Anzeige von Informationen im Verwaltungsbereich und für die Durchführung einer bestimmten Operation zuständig sind.
<?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;
}
}
Der Administrator kann im Admin-Panel die Liste der Anfragen einsehen und je nach Situation entscheiden, ob er die Transaktion genehmigen oder ablehnen will. Im Falle der Genehmigung wird die Handle-Methode der Klasse CoreApproval aufgerufen, die die Aktion direkt ausführt. Im Falle einer Ablehnung wird der Status des Antrags geändert, die Zeit und der Benutzer, der die Aktion durchgeführt hat, werden aufgezeichnet.
<?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();
}
}
Wir ändern nur den Status, wir führen nichts aus.
Wenn der Administrator beschließt, die Aufgabe zu genehmigen, müssen wir eine Methode unserer abstrakten Klasse ausführen:
<?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();
}
}
So sieht die Tabelle Filament aus, in der Sie Aufgaben verwalten können:
<?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'));
}
}
Vorteile:
1. die Sicherheit - risikoreiche Transaktionen werden einer zusätzlichen Überprüfung unterzogen.
2. Flexibilität - Sie können leicht neue Arten von Vorgängen hinzufügen, indem Sie die entsprechende von CoreApproval geerbte Klasse implementieren.
3. Logging und Auditing - die Erfassung von Status und Änderungen ermöglicht die Analyse der Transaktionshistorie.
4. die Integration mit dem Admin-Panel - die Möglichkeit, visuell über die Schnittstelle zu arbeiten, reduziert die Komplexität der Verwaltung.
Nachteilige Aspekte:
1. Zusätzliche Komplexität - das Genehmigungssystem erfordert die Erstellung zusätzlicher Modelle und Klassen, was die Architektur verkomplizieren kann.
2. Verzögerungen bei der Ausführung von Vorgängen - die manuelle Genehmigung kann den Ausführungsprozess verlangsamen, was in einigen Fällen nicht akzeptabel ist.
3. Mögliche Probleme bei der Datensynchronisation, wenn Anwendungen parallel verarbeitet werden.
Das Genehmigungs-Workflow-Muster eignet sich für alle Fälle, in denen die Folgen der Ausführung einer Transaktion kritisch sind:
Möglichkeiten für weitere Verbesserungen
Anwendungsbereiche
Dieses Muster kann auf alle Systeme angewandt werden, bei denen eine Kontrolle über die Ausführung riskanter Transaktionen erforderlich ist. Dies könnte der Finanzsektor, Vertragsverwaltungssysteme, Buchhaltung und Geschäfte sein, bei denen riskante Wertpapier- oder Preisänderungen eine zusätzliche Überprüfung erfordern.
Vergessen Sie die Tests nicht. So könnte ein Mocks-fähiger Test für phpunit aussehen:
<?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);
}
}
Und ein Test des Registrierungsprozesses würde wie folgt aussehen:
<?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);
}
}
Die Implementierung des Approval Workflow Patterns in Laravel bietet ein leistungsfähiges Kontrollinstrument für kritische Geschäftsabläufe. Die detaillierte Aufteilung der Logik in Schritte trägt nicht nur zur Verbesserung der Sicherheit bei, sondern macht das System auch flexibel und erweiterbar. Ganz gleich, ob Sie es für die Neuberechnung von Strafen oder andere gefährliche Aktivitäten verwenden, dieses Muster hilft, Kontrolle, Transparenz und Überprüfbarkeit zu gewährleisten, was sich letztendlich positiv auf den Ruf und die Zuverlässigkeit Ihres Unternehmens auswirkt.
©Sergey Emelyanov 2025 | Alle Rechte vorbehalten