Workflow-Automatisierung: Digitale Freigabeprozesse & Genehmigungen
Prozesse & Workflows

Workflow-Automatisierung: Digitale Freigabeprozesse & Genehmigungen

Carola Schulte
3. März 2025
18 min

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:

  1. State-Machine als Kern: Klare Zustände und Übergänge definieren
  2. Audit-Trail: Jede Aktion dokumentieren (Compliance!)
  3. Eskalation: Timeouts und automatische Erinnerungen einbauen
  4. Vertretungen: Urlaub und Krankheit einplanen
  5. 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.

Carola Schulte

Ü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