Workflow-Automatisierung: Digitale Freigabeprozesse & Genehmigungen
Workflow-Automatisierung: Digitale Freigabeprozesse & Genehmigungen
“Der Antrag liegt seit drei Wochen bei Klaus auf dem Schreibtisch.” Kennen Sie das? Papierbasierte Freigabeprozesse sind langsam, intransparent und fehleranfällig. Digitale Workflows lösen das - wenn sie richtig implementiert sind.
In diesem Artikel zeige ich Ihnen, wie Sie Freigabeprozesse mit PHP und PostgreSQL digitalisieren: von der State-Machine über Eskalationslogik bis zu E-Mail-Benachrichtigungen. Mit konkretem Code, den Sie anpassen können.
Was ist ein Workflow?
Ein Workflow ist eine Abfolge von Zuständen und Übergängen. Im Kern eine State-Machine:
┌──────────────────┐
│ submitted │
└────────┬─────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ approved_l1 │ │ rejected │
└───────┬────────┘ └────────────────┘
│
┌────────────┴────────────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ approved_l2 │ │ rejected │
└───────┬────────┘ └────────────────┘
│
▼
┌────────────────┐
│ completed │
└────────────────┘
Typische Workflow-Anwendungsfälle:
- Urlaubsanträge: Mitarbeiter → Teamleiter → HR
- Beschaffungsanträge: Anforderer → Budgetverantwortlicher → Einkauf
- Rechnungsfreigaben: Eingang → Fachbereich → Buchhaltung
- Vertragsfreigaben: Ersteller → Rechtsabteilung → Geschäftsführung
- Stellenanforderungen: Abteilung → HR → Betriebsrat → Geschäftsführung
Datenmodell für Workflows
Bevor wir Code schreiben, brauchen wir ein solides Datenmodell:
-- Workflow-Definitionen (konfigurierbar)
CREATE TABLE workflow_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
initial_state VARCHAR(50) NOT NULL,
config JSONB NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Workflow-Schritte/Zustände
CREATE TABLE workflow_states (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflow_definitions(id),
name VARCHAR(50) NOT NULL,
display_name VARCHAR(100) NOT NULL,
is_final BOOLEAN NOT NULL DEFAULT false,
timeout_hours INT, -- NULL = kein Timeout
escalation_to UUID REFERENCES workflow_states(id),
config JSONB NOT NULL DEFAULT '{}',
UNIQUE(workflow_id, name)
);
-- Erlaubte Übergänge
CREATE TABLE workflow_transitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflow_definitions(id),
from_state VARCHAR(50) NOT NULL,
to_state VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL, -- 'approve', 'reject', 'escalate', etc.
required_role VARCHAR(100), -- Wer darf diesen Übergang auslösen?
conditions JSONB DEFAULT '{}', -- Zusätzliche Bedingungen
UNIQUE(workflow_id, from_state, action)
);
-- Workflow-Instanzen (konkrete Anträge)
CREATE TABLE workflow_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflow_definitions(id),
reference_type VARCHAR(100) NOT NULL, -- 'vacation_request', 'purchase_order', etc.
reference_id UUID NOT NULL, -- ID des verknüpften Objekts
current_state VARCHAR(50) NOT NULL,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
UNIQUE(reference_type, reference_id)
);
-- Workflow-Historie (Audit-Trail)
CREATE TABLE workflow_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES workflow_instances(id),
from_state VARCHAR(50),
to_state VARCHAR(50) NOT NULL,
action VARCHAR(50) NOT NULL,
performed_by UUID NOT NULL,
performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
comment TEXT,
metadata JSONB DEFAULT '{}'
);
-- Index für schnelle Abfragen
CREATE INDEX idx_workflow_instances_state ON workflow_instances(current_state);
CREATE INDEX idx_workflow_instances_ref ON workflow_instances(reference_type, reference_id);
CREATE INDEX idx_workflow_history_instance ON workflow_history(instance_id);
CREATE INDEX idx_workflow_history_performed_at ON workflow_history(performed_at);
Workflow-Engine implementieren
Der Kern: Eine Engine, die Zustandsübergänge validiert und ausführt:
<?php
declare(strict_types=1);
class WorkflowEngine
{
public function __construct(
private readonly PDO $db,
private readonly WorkflowNotifier $notifier,
private readonly LoggerInterface $logger
) {}
/**
* Startet einen neuen Workflow
*/
public function start(
string $workflowName,
string $referenceType,
string $referenceId,
string $createdBy,
array $metadata = []
): WorkflowInstance {
$definition = $this->getDefinition($workflowName);
if (!$definition) {
throw new WorkflowException("Unknown workflow: {$workflowName}");
}
$instanceId = $this->generateUuid();
$stmt = $this->db->prepare(
'INSERT INTO workflow_instances
(id, workflow_id, reference_type, reference_id, current_state, created_by, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$instanceId,
$definition['id'],
$referenceType,
$referenceId,
$definition['initial_state'],
$createdBy,
json_encode($metadata),
]);
// Initiale Historie schreiben
$this->recordHistory(
$instanceId,
null,
$definition['initial_state'],
'created',
$createdBy
);
$instance = $this->getInstance($instanceId);
// Benachrichtigung an zuständige Personen
$this->notifier->notifyStateEntered($instance);
$this->logger->info('Workflow started', [
'workflow' => $workflowName,
'instance_id' => $instanceId,
'reference' => "{$referenceType}:{$referenceId}",
]);
return $instance;
}
/**
* Führt eine Aktion auf einem Workflow aus
*/
public function transition(
string $instanceId,
string $action,
string $performedBy,
?string $comment = null,
array $metadata = []
): WorkflowInstance {
$this->db->beginTransaction();
try {
// Instanz mit Lock laden (FOR UPDATE)
$instance = $this->getInstanceForUpdate($instanceId);
if ($instance === null) {
throw new WorkflowException("Instance not found: {$instanceId}");
}
if ($instance['completed_at'] !== null) {
throw new WorkflowException("Workflow already completed");
}
// Transition finden
$transition = $this->findTransition(
$instance['workflow_id'],
$instance['current_state'],
$action
);
if ($transition === null) {
throw new WorkflowException(
"Invalid action '{$action}' for state '{$instance['current_state']}'"
);
}
// Berechtigung prüfen
if (!$this->canPerformTransition($transition, $performedBy, $instance)) {
throw new WorkflowException("User not authorized for this action");
}
// Bedingungen prüfen
if (!$this->checkConditions($transition, $instance)) {
throw new WorkflowException("Transition conditions not met");
}
$fromState = $instance['current_state'];
$toState = $transition['to_state'];
// Zustand aktualisieren
$this->updateInstanceState($instanceId, $toState);
// Historie schreiben
$this->recordHistory(
$instanceId,
$fromState,
$toState,
$action,
$performedBy,
$comment,
$metadata
);
// Prüfen ob Workflow abgeschlossen
if ($this->isFinalState($instance['workflow_id'], $toState)) {
$this->markCompleted($instanceId);
}
$this->db->commit();
$updatedInstance = $this->getInstance($instanceId);
// Benachrichtigungen senden (nach Commit!)
$this->notifier->notifyTransition($updatedInstance, $fromState, $action, $performedBy);
$this->logger->info('Workflow transition', [
'instance_id' => $instanceId,
'from' => $fromState,
'to' => $toState,
'action' => $action,
'by' => $performedBy,
]);
return $updatedInstance;
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
/**
* Verfügbare Aktionen für aktuellen Zustand
*/
public function getAvailableActions(string $instanceId, string $userId): array
{
$instance = $this->getInstance($instanceId);
if ($instance === null || $instance['completed_at'] !== null) {
return [];
}
$stmt = $this->db->prepare(
'SELECT * FROM workflow_transitions
WHERE workflow_id = ? AND from_state = ?'
);
$stmt->execute([$instance['workflow_id'], $instance['current_state']]);
$transitions = $stmt->fetchAll(PDO::FETCH_ASSOC);
$available = [];
foreach ($transitions as $transition) {
if ($this->canPerformTransition($transition, $userId, $instance)) {
$available[] = [
'action' => $transition['action'],
'to_state' => $transition['to_state'],
];
}
}
return $available;
}
private function getInstanceForUpdate(string $instanceId): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM workflow_instances WHERE id = ? FOR UPDATE'
);
$stmt->execute([$instanceId]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
private function findTransition(
string $workflowId,
string $fromState,
string $action
): ?array {
$stmt = $this->db->prepare(
'SELECT * FROM workflow_transitions
WHERE workflow_id = ? AND from_state = ? AND action = ?'
);
$stmt->execute([$workflowId, $fromState, $action]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
private function canPerformTransition(
array $transition,
string $userId,
array $instance
): bool {
// Keine Rollenprüfung konfiguriert = jeder darf
if (empty($transition['required_role'])) {
return true;
}
// Spezielle Rollen
$requiredRole = $transition['required_role'];
// CREATOR = nur der Ersteller
if ($requiredRole === 'CREATOR') {
return $userId === $instance['created_by'];
}
// APPROVER_L1, APPROVER_L2, etc. = aus Lookup-Tabelle
if (str_starts_with($requiredRole, 'APPROVER_')) {
return $this->isApprover($userId, $instance, $requiredRole);
}
// Normale Rolle = Rollenprüfung
return $this->userHasRole($userId, $requiredRole);
}
private function isApprover(string $userId, array $instance, string $level): bool
{
// Approver aus Metadaten oder separater Tabelle ermitteln
$metadata = json_decode($instance['metadata'], true);
// Variante 1: Approver in Metadaten hinterlegt
$approvers = $metadata['approvers'][$level] ?? [];
if (in_array($userId, $approvers, true)) {
return true;
}
// Variante 2: Lookup basierend auf Reference (z.B. Manager des Antragstellers)
return $this->lookupApprover($instance, $level) === $userId;
}
private function lookupApprover(array $instance, string $level): ?string
{
// Hier: Manager-Hierarchie abfragen
// In Produktion: eigene Tabelle oder HR-System-Anbindung
$stmt = $this->db->prepare(
'SELECT manager_id FROM employees WHERE user_id = ?'
);
$stmt->execute([$instance['created_by']]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row['manager_id'] ?? null;
}
private function checkConditions(array $transition, array $instance): bool
{
$conditions = json_decode($transition['conditions'] ?? '{}', true);
if (empty($conditions)) {
return true;
}
$metadata = json_decode($instance['metadata'], true);
// Beispiel: amount_threshold
if (isset($conditions['max_amount'])) {
$amount = $metadata['amount'] ?? 0;
if ($amount > $conditions['max_amount']) {
return false;
}
}
return true;
}
private function updateInstanceState(string $instanceId, string $newState): void
{
$stmt = $this->db->prepare(
'UPDATE workflow_instances
SET current_state = ?, updated_at = NOW()
WHERE id = ?'
);
$stmt->execute([$newState, $instanceId]);
}
private function recordHistory(
string $instanceId,
?string $fromState,
string $toState,
string $action,
string $performedBy,
?string $comment = null,
array $metadata = []
): void {
$stmt = $this->db->prepare(
'INSERT INTO workflow_history
(id, instance_id, from_state, to_state, action, performed_by, comment, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([
$this->generateUuid(),
$instanceId,
$fromState,
$toState,
$action,
$performedBy,
$comment,
json_encode($metadata),
]);
}
private function isFinalState(string $workflowId, string $state): bool
{
$stmt = $this->db->prepare(
'SELECT is_final FROM workflow_states
WHERE workflow_id = ? AND name = ?'
);
$stmt->execute([$workflowId, $state]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return (bool)($row['is_final'] ?? false);
}
private function markCompleted(string $instanceId): void
{
$stmt = $this->db->prepare(
'UPDATE workflow_instances
SET completed_at = NOW(), updated_at = NOW()
WHERE id = ?'
);
$stmt->execute([$instanceId]);
}
private function generateUuid(): string
{
// In PHP 8.2+: Native Funktion nutzen
// Ansonsten: ramsey/uuid oder ähnlich
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
// Weitere Helper-Methoden...
}
Benachrichtigungen implementieren
Niemand will ständig nachfragen, ob sein Antrag bearbeitet wurde:
<?php
declare(strict_types=1);
class WorkflowNotifier
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly UserRepository $users,
private readonly ApproverResolver $approverResolver,
private readonly TemplateEngine $templates,
private readonly string $baseUrl
) {}
public function notifyStateEntered(WorkflowInstance $instance): void
{
// Wer ist für den aktuellen Zustand zuständig?
$approvers = $this->approverResolver->resolve($instance);
foreach ($approvers as $approver) {
$this->sendNotification(
$approver,
'workflow_pending',
[
'instance' => $instance,
'action_url' => $this->buildActionUrl($instance),
]
);
}
}
public function notifyTransition(
WorkflowInstance $instance,
string $fromState,
string $action,
string $performedBy
): void {
$performer = $this->users->findById($performedBy);
$creator = $this->users->findById($instance->createdBy);
// Ersteller informieren
if ($creator->id !== $performedBy) {
$this->sendNotification(
$creator,
'workflow_updated',
[
'instance' => $instance,
'from_state' => $fromState,
'action' => $action,
'performer' => $performer,
]
);
}
// Bei Abschluss: Alle Beteiligten informieren
if ($instance->isCompleted()) {
$this->notifyCompletion($instance, $action);
} else {
// Nächste Genehmiger informieren
$this->notifyStateEntered($instance);
}
}
public function sendReminder(WorkflowInstance $instance): void
{
$approvers = $this->approverResolver->resolve($instance);
foreach ($approvers as $approver) {
$this->sendNotification(
$approver,
'workflow_reminder',
[
'instance' => $instance,
'action_url' => $this->buildActionUrl($instance),
'waiting_since' => $instance->updatedAt,
]
);
}
}
private function sendNotification(
User $recipient,
string $template,
array $context
): void {
// E-Mail-Präferenzen prüfen
if (!$recipient->wantsEmailNotifications()) {
return;
}
$subject = $this->templates->render("{$template}_subject", $context);
$body = $this->templates->render("{$template}_body", $context);
$email = (new Email())
->to($recipient->email)
->subject($subject)
->html($body);
$this->mailer->send($email);
}
private function buildActionUrl(WorkflowInstance $instance): string
{
return sprintf(
'%s/workflows/%s',
rtrim($this->baseUrl, '/'),
$instance->id
);
}
private function notifyCompletion(WorkflowInstance $instance, string $finalAction): void
{
// Alle aus der Historie ermitteln
$participants = $this->getParticipants($instance);
foreach ($participants as $participant) {
$this->sendNotification(
$participant,
'workflow_completed',
[
'instance' => $instance,
'final_action' => $finalAction, // approved, rejected, etc.
]
);
}
}
private function getParticipants(WorkflowInstance $instance): array
{
// Aus Historie alle Beteiligten sammeln
$stmt = $this->db->prepare(
'SELECT DISTINCT performed_by FROM workflow_history
WHERE instance_id = ?'
);
$stmt->execute([$instance->id]);
$userIds = $stmt->fetchAll(PDO::FETCH_COLUMN);
return array_map(
fn($id) => $this->users->findById($id),
$userIds
);
}
}
E-Mail-Templates
<?php
// templates/workflow_pending_body.php
?>
<!DOCTYPE html>
<html>
<head>
<style>
.container { max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; }
.header { background: #2563eb; color: white; padding: 20px; }
.content { padding: 20px; }
.button { display: inline-block; padding: 12px 24px; background: #2563eb;
color: white; text-decoration: none; border-radius: 4px; }
.meta { color: #666; font-size: 14px; margin-top: 20px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Freigabe erforderlich</h1>
</div>
<div class="content">
<p>Hallo <?= htmlspecialchars($recipient->firstName) ?>,</p>
<p>Ein neuer <strong><?= htmlspecialchars($instance->getTypeName()) ?></strong>
wartet auf Ihre Freigabe:</p>
<table>
<tr>
<td>Antragsteller:</td>
<td><?= htmlspecialchars($instance->getCreatorName()) ?></td>
</tr>
<tr>
<td>Eingereicht am:</td>
<td><?= $instance->createdAt->format('d.m.Y H:i') ?></td>
</tr>
<?php if ($amount = $instance->getMetadata('amount')): ?>
<tr>
<td>Betrag:</td>
<td><?= number_format($amount, 2, ',', '.') ?> €</td>
</tr>
<?php endif; ?>
</table>
<p style="margin-top: 20px;">
<a href="<?= htmlspecialchars($action_url) ?>" class="button">
Antrag prüfen
</a>
</p>
<div class="meta">
<p>Diese E-Mail wurde automatisch generiert.<br>
Bitte antworten Sie nicht direkt auf diese E-Mail.</p>
</div>
</div>
</div>
</body>
</html>
Eskalationslogik
Was passiert, wenn Klaus drei Wochen im Urlaub ist? Eskalation:
<?php
declare(strict_types=1);
class WorkflowEscalationService
{
public function __construct(
private readonly PDO $db,
private readonly WorkflowEngine $engine,
private readonly WorkflowNotifier $notifier,
private readonly LoggerInterface $logger
) {}
/**
* Als Cron-Job alle 15 Minuten ausführen
*/
public function processEscalations(): void
{
$overdueInstances = $this->findOverdueInstances();
foreach ($overdueInstances as $instance) {
$this->processInstance($instance);
}
$this->logger->info('Escalation run completed', [
'processed' => count($overdueInstances),
]);
}
private function findOverdueInstances(): array
{
// Instanzen finden, deren Timeout überschritten ist
$stmt = $this->db->query(
"SELECT wi.*, ws.timeout_hours, ws.escalation_to
FROM workflow_instances wi
JOIN workflow_states ws
ON ws.workflow_id = wi.workflow_id
AND ws.name = wi.current_state
WHERE wi.completed_at IS NULL
AND ws.timeout_hours IS NOT NULL
AND wi.updated_at < NOW() - (ws.timeout_hours || ' hours')::INTERVAL"
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function processInstance(array $instance): void
{
$instanceId = $instance['id'];
// Wurde schon eskaliert? (innerhalb der letzten X Stunden)
if ($this->wasRecentlyEscalated($instanceId)) {
return;
}
// Eskalations-Strategie aus Config
$strategy = $this->getEscalationStrategy($instance);
match ($strategy) {
'reminder' => $this->sendReminder($instance),
'escalate_to_manager' => $this->escalateToManager($instance),
'auto_approve' => $this->autoApprove($instance),
'auto_reject' => $this->autoReject($instance),
default => $this->sendReminder($instance),
};
}
private function sendReminder(array $instance): void
{
$workflowInstance = $this->engine->getInstance($instance['id']);
$this->notifier->sendReminder($workflowInstance);
$this->recordEscalation($instance['id'], 'reminder');
$this->logger->info('Reminder sent', [
'instance_id' => $instance['id'],
]);
}
private function escalateToManager(array $instance): void
{
// Nächsthöheren Manager ermitteln
$currentApprovers = $this->getCurrentApprovers($instance);
$escalatedApprovers = [];
foreach ($currentApprovers as $approver) {
$manager = $this->getManager($approver);
if ($manager) {
$escalatedApprovers[] = $manager;
}
}
if (empty($escalatedApprovers)) {
// Kein Manager gefunden - nur Reminder
$this->sendReminder($instance);
return;
}
// Approver in Metadaten aktualisieren
$this->updateApprovers($instance['id'], $escalatedApprovers);
// Neue Approver benachrichtigen
$workflowInstance = $this->engine->getInstance($instance['id']);
$this->notifier->notifyEscalation($workflowInstance, $currentApprovers, $escalatedApprovers);
$this->recordEscalation($instance['id'], 'escalate_to_manager', [
'from' => $currentApprovers,
'to' => $escalatedApprovers,
]);
$this->logger->warning('Escalated to manager', [
'instance_id' => $instance['id'],
'from' => $currentApprovers,
'to' => $escalatedApprovers,
]);
}
private function autoApprove(array $instance): void
{
// VORSICHT: Nur für unkritische Workflows!
// Audit-Compliance prüfen!
$this->engine->transition(
$instance['id'],
'approve',
'SYSTEM', // System-User
'Automatisch genehmigt nach Timeout'
);
$this->recordEscalation($instance['id'], 'auto_approve');
$this->logger->warning('Auto-approved after timeout', [
'instance_id' => $instance['id'],
]);
}
private function autoReject(array $instance): void
{
$this->engine->transition(
$instance['id'],
'reject',
'SYSTEM',
'Automatisch abgelehnt nach Timeout - bitte erneut einreichen'
);
$this->recordEscalation($instance['id'], 'auto_reject');
$this->logger->warning('Auto-rejected after timeout', [
'instance_id' => $instance['id'],
]);
}
private function wasRecentlyEscalated(string $instanceId): bool
{
// Cooldown: Nicht öfter als alle 24h eskalieren
$stmt = $this->db->prepare(
"SELECT COUNT(*) FROM workflow_escalations
WHERE instance_id = ?
AND created_at > NOW() - INTERVAL '24 hours'"
);
$stmt->execute([$instanceId]);
return $stmt->fetchColumn() > 0;
}
private function recordEscalation(
string $instanceId,
string $action,
array $metadata = []
): void {
$stmt = $this->db->prepare(
'INSERT INTO workflow_escalations (instance_id, action, metadata, created_at)
VALUES (?, ?, ?, NOW())'
);
$stmt->execute([$instanceId, $action, json_encode($metadata)]);
}
private function getEscalationStrategy(array $instance): string
{
// Aus State-Config oder Workflow-Config lesen
$stateConfig = json_decode($instance['config'] ?? '{}', true);
return $stateConfig['escalation_strategy'] ?? 'reminder';
}
// Helper-Methoden für Manager-Lookup, Approver-Updates, etc.
}
Cron-Job einrichten
# /etc/cron.d/workflow-escalation
*/15 * * * * www-data /usr/bin/php /var/www/app/bin/console workflow:escalate >> /var/log/workflow-escalation.log 2>&1
Praxisbeispiel: Urlaubsantrag
Komplett durchimplementiert:
<?php
declare(strict_types=1);
// 1. Workflow-Definition (einmalig, z.B. via Migration)
class CreateVacationWorkflowMigration
{
public function up(PDO $db): void
{
// Workflow anlegen
$workflowId = $this->createUuid();
$db->prepare(
"INSERT INTO workflow_definitions (id, name, description, initial_state)
VALUES (?, 'vacation_request', 'Urlaubsantrag', 'submitted')"
)->execute([$workflowId]);
// States
$states = [
['submitted', 'Eingereicht', false, 72, 'reminder'], // 72h Timeout
['approved_manager', 'Vom Vorgesetzten genehmigt', false, 48, null],
['approved', 'Genehmigt', true, null, null],
['rejected', 'Abgelehnt', true, null, null],
];
foreach ($states as [$name, $display, $final, $timeout, $escalation]) {
$db->prepare(
"INSERT INTO workflow_states
(id, workflow_id, name, display_name, is_final, timeout_hours)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$this->createUuid(),
$workflowId,
$name,
$display,
$final ? 1 : 0,
$timeout,
]);
}
// Transitions
$transitions = [
['submitted', 'approved_manager', 'approve', 'APPROVER_L1'],
['submitted', 'rejected', 'reject', 'APPROVER_L1'],
['approved_manager', 'approved', 'approve', 'HR_MANAGER'],
['approved_manager', 'rejected', 'reject', 'HR_MANAGER'],
['submitted', 'submitted', 'withdraw', 'CREATOR'], // Zurückziehen
];
foreach ($transitions as [$from, $to, $action, $role]) {
$db->prepare(
"INSERT INTO workflow_transitions
(id, workflow_id, from_state, to_state, action, required_role)
VALUES (?, ?, ?, ?, ?, ?)"
)->execute([
$this->createUuid(),
$workflowId,
$from,
$to,
$action,
$role,
]);
}
}
}
// 2. Service für Urlaubsanträge
class VacationRequestService
{
public function __construct(
private readonly PDO $db,
private readonly WorkflowEngine $workflow,
private readonly VacationValidator $validator
) {}
public function submit(VacationRequestData $data, string $userId): VacationRequest
{
// Validierung
$this->validator->validate($data, $userId);
$this->db->beginTransaction();
try {
// Urlaubsantrag speichern
$requestId = $this->createUuid();
$stmt = $this->db->prepare(
'INSERT INTO vacation_requests
(id, user_id, start_date, end_date, days, reason, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())'
);
$stmt->execute([
$requestId,
$userId,
$data->startDate->format('Y-m-d'),
$data->endDate->format('Y-m-d'),
$data->days,
$data->reason,
]);
// Workflow starten
$manager = $this->getManager($userId);
$this->workflow->start(
'vacation_request',
'vacation_request',
$requestId,
$userId,
[
'days' => $data->days,
'approvers' => [
'APPROVER_L1' => [$manager->id],
],
]
);
$this->db->commit();
return $this->findById($requestId);
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
public function approve(string $requestId, string $approverId, ?string $comment = null): void
{
$request = $this->findById($requestId);
$instance = $this->getWorkflowInstance($requestId);
$this->workflow->transition(
$instance->id,
'approve',
$approverId,
$comment
);
// Wenn komplett genehmigt: Urlaubstage abziehen
$updatedInstance = $this->getWorkflowInstance($requestId);
if ($updatedInstance->isCompleted() && $updatedInstance->currentState === 'approved') {
$this->deductVacationDays($request);
}
}
public function reject(string $requestId, string $approverId, string $reason): void
{
$instance = $this->getWorkflowInstance($requestId);
$this->workflow->transition(
$instance->id,
'reject',
$approverId,
$reason
);
}
public function getMyRequests(string $userId): array
{
$stmt = $this->db->prepare(
"SELECT vr.*, wi.current_state as workflow_state
FROM vacation_requests vr
JOIN workflow_instances wi
ON wi.reference_type = 'vacation_request'
AND wi.reference_id = vr.id
WHERE vr.user_id = ?
ORDER BY vr.created_at DESC"
);
$stmt->execute([$userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getPendingApprovals(string $approverId): array
{
// Anträge, bei denen ich der nächste Genehmiger bin
$stmt = $this->db->prepare(
"SELECT vr.*, wi.id as workflow_id, wi.current_state, u.name as requester_name
FROM vacation_requests vr
JOIN workflow_instances wi
ON wi.reference_type = 'vacation_request'
AND wi.reference_id = vr.id
JOIN users u ON u.id = vr.user_id
WHERE wi.completed_at IS NULL
AND (
-- Direkter Manager
EXISTS (
SELECT 1 FROM employees e
WHERE e.user_id = vr.user_id
AND e.manager_id = ?
)
-- Oder HR-Manager für zweite Stufe
OR (
wi.current_state = 'approved_manager'
AND EXISTS (
SELECT 1 FROM user_roles ur
WHERE ur.user_id = ? AND ur.role = 'HR_MANAGER'
)
)
)
ORDER BY vr.created_at ASC"
);
$stmt->execute([$approverId, $approverId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Helper-Methoden...
}
API-Endpoints
<?php
// Controller für REST-API
class VacationRequestController
{
public function __construct(
private readonly VacationRequestService $service,
private readonly WorkflowEngine $workflow
) {}
// POST /api/vacation-requests
public function submit(Request $request): JsonResponse
{
$userId = $request->getAttribute('user_id');
$data = new VacationRequestData(
startDate: new DateTimeImmutable($request->input('start_date')),
endDate: new DateTimeImmutable($request->input('end_date')),
days: (int)$request->input('days'),
reason: $request->input('reason'),
);
try {
$vacationRequest = $this->service->submit($data, $userId);
return new JsonResponse([
'id' => $vacationRequest->id,
'status' => 'submitted',
'message' => 'Urlaubsantrag eingereicht',
], 201);
} catch (ValidationException $e) {
return new JsonResponse([
'error' => $e->getMessage(),
'details' => $e->getErrors(),
], 422);
}
}
// POST /api/vacation-requests/{id}/approve
public function approve(Request $request, string $id): JsonResponse
{
$userId = $request->getAttribute('user_id');
$comment = $request->input('comment');
try {
$this->service->approve($id, $userId, $comment);
return new JsonResponse([
'status' => 'approved',
'message' => 'Antrag genehmigt',
]);
} catch (WorkflowException $e) {
return new JsonResponse([
'error' => $e->getMessage(),
], 400);
}
}
// POST /api/vacation-requests/{id}/reject
public function reject(Request $request, string $id): JsonResponse
{
$userId = $request->getAttribute('user_id');
$reason = $request->input('reason');
if (empty($reason)) {
return new JsonResponse([
'error' => 'Ablehnungsgrund erforderlich',
], 422);
}
try {
$this->service->reject($id, $userId, $reason);
return new JsonResponse([
'status' => 'rejected',
'message' => 'Antrag abgelehnt',
]);
} catch (WorkflowException $e) {
return new JsonResponse([
'error' => $e->getMessage(),
], 400);
}
}
// GET /api/vacation-requests/pending
public function pending(Request $request): JsonResponse
{
$userId = $request->getAttribute('user_id');
$pending = $this->service->getPendingApprovals($userId);
return new JsonResponse([
'data' => $pending,
'count' => count($pending),
]);
}
}
Parallele Genehmigungen
Manchmal müssen mehrere Personen gleichzeitig genehmigen:
<?php
declare(strict_types=1);
class ParallelApprovalHandler
{
public function __construct(
private readonly PDO $db,
private readonly WorkflowEngine $engine
) {}
/**
* Parallele Genehmigung: Alle müssen zustimmen
*/
public function recordApproval(
string $instanceId,
string $approverId,
string $decision, // 'approve' oder 'reject'
?string $comment = null
): void {
$instance = $this->engine->getInstance($instanceId);
$config = $instance->getStateConfig();
// Prüfen ob parallele Genehmigung konfiguriert
if (!($config['parallel_approval'] ?? false)) {
// Normale Transition
$this->engine->transition($instanceId, $decision, $approverId, $comment);
return;
}
// Parallele Genehmigung aufzeichnen
$this->recordIndividualApproval($instanceId, $approverId, $decision, $comment);
// Prüfen ob alle Genehmigungen vorliegen
$requiredApprovers = $config['required_approvers'] ?? [];
$approvals = $this->getApprovals($instanceId);
// Bei einer Ablehnung: Sofort ablehnen
if ($decision === 'reject') {
$this->engine->transition($instanceId, 'reject', $approverId, $comment);
return;
}
// Alle genehmigt?
$allApproved = true;
foreach ($requiredApprovers as $required) {
if (!isset($approvals[$required]) || $approvals[$required] !== 'approve') {
$allApproved = false;
break;
}
}
if ($allApproved) {
$this->engine->transition(
$instanceId,
'approve',
'SYSTEM', // Alle haben genehmigt
'Alle erforderlichen Genehmigungen erteilt'
);
}
}
private function recordIndividualApproval(
string $instanceId,
string $approverId,
string $decision,
?string $comment
): void {
$stmt = $this->db->prepare(
'INSERT INTO workflow_parallel_approvals
(instance_id, approver_id, decision, comment, created_at)
VALUES (?, ?, ?, ?, NOW())
ON CONFLICT (instance_id, approver_id)
DO UPDATE SET decision = ?, comment = ?, created_at = NOW()'
);
$stmt->execute([
$instanceId,
$approverId,
$decision,
$comment,
$decision,
$comment,
]);
}
private function getApprovals(string $instanceId): array
{
$stmt = $this->db->prepare(
'SELECT approver_id, decision FROM workflow_parallel_approvals
WHERE instance_id = ?'
);
$stmt->execute([$instanceId]);
$approvals = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$approvals[$row['approver_id']] = $row['decision'];
}
return $approvals;
}
}
Vertretungsregelungen
Klaus ist im Urlaub? Sein Vertreter übernimmt:
<?php
declare(strict_types=1);
class SubstituteResolver
{
public function __construct(
private readonly PDO $db
) {}
/**
* Ermittelt den aktuell zuständigen Genehmiger
* (Original oder Vertreter)
*/
public function resolveApprover(string $originalApproverId): string
{
// Aktive Vertretung suchen
$stmt = $this->db->prepare(
"SELECT substitute_id FROM user_substitutes
WHERE user_id = ?
AND NOW() BETWEEN valid_from AND valid_until
AND is_active = true
ORDER BY created_at DESC
LIMIT 1"
);
$stmt->execute([$originalApproverId]);
$substitute = $stmt->fetchColumn();
return $substitute ?: $originalApproverId;
}
/**
* Alle Approver mit Vertretungslogik auflösen
*/
public function resolveApprovers(array $originalApproverIds): array
{
$resolved = [];
foreach ($originalApproverIds as $approverId) {
$resolved[] = $this->resolveApprover($approverId);
}
return array_unique($resolved);
}
/**
* Prüft ob User für jemanden vertritt
*/
public function isSubstituteFor(string $substituteId, string $originalId): bool
{
$stmt = $this->db->prepare(
"SELECT COUNT(*) FROM user_substitutes
WHERE user_id = ?
AND substitute_id = ?
AND NOW() BETWEEN valid_from AND valid_until
AND is_active = true"
);
$stmt->execute([$originalId, $substituteId]);
return $stmt->fetchColumn() > 0;
}
}
// Integration in WorkflowEngine
class WorkflowEngine
{
// ... bestehender Code ...
private function canPerformTransition(
array $transition,
string $userId,
array $instance
): bool {
// Direkte Berechtigung prüfen
if ($this->hasDirectPermission($transition, $userId, $instance)) {
return true;
}
// Vertretungsberechtigung prüfen
$originalApprovers = $this->getOriginalApprovers($instance, $transition);
foreach ($originalApprovers as $originalApprover) {
if ($this->substituteResolver->isSubstituteFor($userId, $originalApprover)) {
return true;
}
}
return false;
}
}
Reporting und Dashboards
Workflow-Metriken für Management:
-- Durchschnittliche Bearbeitungszeit pro Workflow
SELECT
wd.name as workflow,
ws.display_name as state,
COUNT(*) as total,
AVG(EXTRACT(EPOCH FROM (
LEAD(wh.performed_at) OVER (PARTITION BY wh.instance_id ORDER BY wh.performed_at)
- wh.performed_at
)) / 3600) as avg_hours_in_state
FROM workflow_history wh
JOIN workflow_instances wi ON wi.id = wh.instance_id
JOIN workflow_definitions wd ON wd.id = wi.workflow_id
JOIN workflow_states ws ON ws.workflow_id = wd.id AND ws.name = wh.to_state
WHERE wh.performed_at > NOW() - INTERVAL '30 days'
GROUP BY wd.name, ws.display_name
ORDER BY wd.name, avg_hours_in_state DESC;
-- Offene Anträge pro Genehmiger (Bottleneck-Analyse)
SELECT
u.name as approver,
COUNT(*) as pending_count,
MIN(wi.updated_at) as oldest_pending
FROM workflow_instances wi
JOIN users u ON u.id = (
SELECT performed_by FROM workflow_history
WHERE instance_id = wi.id
ORDER BY performed_at DESC LIMIT 1
)
WHERE wi.completed_at IS NULL
GROUP BY u.id, u.name
ORDER BY pending_count DESC;
-- Ablehnungsquote pro Workflow-Typ
SELECT
wd.name as workflow,
COUNT(*) FILTER (WHERE wi.current_state = 'rejected') as rejected,
COUNT(*) FILTER (WHERE wi.current_state NOT IN ('rejected', 'submitted')) as approved,
ROUND(
100.0 * COUNT(*) FILTER (WHERE wi.current_state = 'rejected') / NULLIF(COUNT(*), 0),
1
) as rejection_rate
FROM workflow_instances wi
JOIN workflow_definitions wd ON wd.id = wi.workflow_id
WHERE wi.completed_at IS NOT NULL
AND wi.created_at > NOW() - INTERVAL '90 days'
GROUP BY wd.name
ORDER BY rejection_rate DESC;
<?php
class WorkflowDashboardService
{
public function __construct(
private readonly PDO $db
) {}
public function getSummary(): array
{
return [
'pending' => $this->getPendingCount(),
'overdue' => $this->getOverdueCount(),
'completed_today' => $this->getCompletedTodayCount(),
'avg_processing_time' => $this->getAverageProcessingTime(),
];
}
public function getBottlenecks(): array
{
$stmt = $this->db->query(
"SELECT
wi.current_state,
COUNT(*) as count,
AVG(EXTRACT(EPOCH FROM (NOW() - wi.updated_at)) / 3600) as avg_waiting_hours
FROM workflow_instances wi
WHERE wi.completed_at IS NULL
GROUP BY wi.current_state
HAVING AVG(EXTRACT(EPOCH FROM (NOW() - wi.updated_at)) / 3600) > 24
ORDER BY avg_waiting_hours DESC"
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getApproverWorkload(): array
{
$stmt = $this->db->query(
"SELECT
u.id,
u.name,
COUNT(*) as pending,
MIN(wi.updated_at) as oldest
FROM workflow_instances wi
CROSS JOIN LATERAL (
SELECT DISTINCT unnest(
ARRAY(SELECT jsonb_array_elements_text(metadata->'approvers'->'APPROVER_L1'))
) as approver_id
) approvers
JOIN users u ON u.id::text = approvers.approver_id
WHERE wi.completed_at IS NULL
GROUP BY u.id, u.name
ORDER BY pending DESC
LIMIT 10"
);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function getPendingCount(): int
{
return (int)$this->db->query(
'SELECT COUNT(*) FROM workflow_instances WHERE completed_at IS NULL'
)->fetchColumn();
}
private function getOverdueCount(): int
{
return (int)$this->db->query(
"SELECT COUNT(*)
FROM workflow_instances wi
JOIN workflow_states ws
ON ws.workflow_id = wi.workflow_id
AND ws.name = wi.current_state
WHERE wi.completed_at IS NULL
AND ws.timeout_hours IS NOT NULL
AND wi.updated_at < NOW() - (ws.timeout_hours || ' hours')::INTERVAL"
)->fetchColumn();
}
private function getCompletedTodayCount(): int
{
return (int)$this->db->query(
"SELECT COUNT(*) FROM workflow_instances
WHERE completed_at >= CURRENT_DATE"
)->fetchColumn();
}
private function getAverageProcessingTime(): float
{
return (float)$this->db->query(
"SELECT AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 3600)
FROM workflow_instances
WHERE completed_at IS NOT NULL
AND created_at > NOW() - INTERVAL '30 days'"
)->fetchColumn();
}
}
Checkliste für Workflow-Projekte
Vor der Implementierung:
- Alle Prozessschritte dokumentiert (wer macht was, wann?)
- Genehmigungshierarchie geklärt (wer genehmigt ab welchem Betrag?)
- Vertretungsregelungen definiert
- Eskalationspfade festgelegt
- SLA-Zeiten vereinbart (max. Bearbeitungszeit pro Schritt)
Technische Anforderungen:
- Audit-Trail für Compliance (wer hat wann was getan?)
- E-Mail-Benachrichtigungen (nicht zu viele!)
- Mobile-Zugriff für Genehmigungen
- Integration in bestehende Systeme (SSO, ERP, etc.)
- Reporting und Dashboards
Go-Live:
- Pilotgruppe definiert
- Schulung für Anwender
- Fallback-Prozess bei technischen Problemen
- Monitoring für Eskalationen eingerichtet
Fazit
Workflow-Automatisierung ist mehr als nur Formulare digitalisieren. Die wichtigsten Erkenntnisse:
- State-Machine als Kern: Klare Zustände und Übergänge definieren
- Audit-Trail: Jede Aktion dokumentieren (Compliance!)
- Eskalation: Timeouts und automatische Erinnerungen einbauen
- Vertretungen: Urlaub und Krankheit einplanen
- Benachrichtigungen: Sparsam aber effektiv
Der größte Fehler: Papier-Prozesse 1:1 digitalisieren. Nutzen Sie die Chance, Prozesse zu hinterfragen und zu optimieren.
Sie planen ein Workflow-Projekt? Sprechen Sie mich an - ich helfe Ihnen bei der Konzeption und Implementierung. Von der Anforderungsanalyse bis zum Go-Live.
Über Carola Schulte
Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.
Projekt im Kopf?
Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.
Kostenloses Erstgespräch