Workflow-Automatisierung: Digitale Freigabeprozesse & Genehmigungen
Prozesse & Workflows

Workflow-Automatisierung: Digitale Freigabeprozesse & Genehmigungen

Carola Schulte
3. März 2025
18 min

“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

Weitere interessante Artikel

KI in der eigenen Webapp: Kundendaten unter Kontrolle

KI in die eigene Webapp holen: drei Wege für die Datenfrage, Prompt-Injection als neuer Angriffsweg, und was sich für KMU wirklich lohnt.

Background-Jobs in PHP: Queues, Cron & lange Prozesse

Background-Jobs in PHP: Queue-Systeme (Redis, RabbitMQ, Database), Cron-Jobs, Worker-Prozesse, Supervisord.

Digitale Führerscheinkontrolle: Systeme für DGUV-gestützte Prozesse

Digitale Führerscheinkontrolle: DGUV-konforme Prüfprozesse als System, mit Hologramm-Siegel, Erinnerungen und lückenloser Dokumentation.