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

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

Carola Schulte
28. April 2025
14 min

“Können Sie nachweisen, dass Herr Müller am 15. März einen gültigen Führerschein hatte?” Diese Frage nach einem Unfall kann teuer werden. Ohne lückenlose Dokumentation haftet der Fuhrparkverantwortliche persönlich - und die Versicherung verweigert möglicherweise die Leistung.

Die gute Nachricht: Digitale Führerscheinkontrolle löst dieses Problem elegant. In diesem Artikel zeige ich Ihnen, wie solche Systeme technisch funktionieren - basierend auf Erfahrungen aus der Entwicklung eines Systems mit holografischen Sicherheitssiegeln.


Rechtlicher Hintergrund: Warum Führerscheinkontrolle Pflicht ist

Straf- und haftungsrechtliche Risiken

Unternehmen tragen bei Dienstfahrzeugen eine Organisationsverantwortung. Wird ein Mitarbeiter ohne gültige Fahrerlaubnis am Steuer erwischt oder verursacht einen Unfall, drohen:

  • Strafrechtliche Konsequenzen für Verantwortliche (u.a. bei Fahren ohne Fahrerlaubnis, Organisationsverschulden)
  • Versicherungsrechtliche Probleme - Leistungskürzung oder -verweigerung bei mangelhafter Dokumentation
  • Persönliche Haftung des Fuhrparkverantwortlichen bei nachgewiesener Pflichtverletzung

Hinweis: Die genauen rechtlichen Anforderungen hängen vom Einzelfall ab. Dieser Artikel ersetzt keine Rechtsberatung.

DGUV Vorschrift 70

Die Deutsche Gesetzliche Unfallversicherung schreibt vor:

“Der Unternehmer darf mit dem selbständigen Führen von maschinell angetriebenen Fahrzeugen nur Versicherte beauftragen, die […] im Besitz eines erforderlichen Führerscheins sind.”

Konkret bedeutet das:

  • Führerscheinkontrolle vor Erstüberlassung: Pflicht
  • Regelmäßige Wiederholungskontrolle: mindestens halbjährlich empfohlen
  • Dokumentation: revisionssicher aufbewahren

Manuelle vs. Digitale Kontrolle

Das Problem mit Excel und Papier

┌─────────────────────────────────────────────────────────────────────┐
│                    MANUELLE FÜHRERSCHEINKONTROLLE                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. Fuhrparkleiter erstellt Excel-Liste mit Terminen               │
│  2. Fahrer kommt (hoffentlich) zum vereinbarten Termin             │
│  3. Führerschein wird angeschaut und Datum notiert                 │
│  4. Bei Außendienst: Termin verschieben, vergessen, ...            │
│  5. Nach 2 Jahren: "Wo ist die Liste von 2023?"                    │
│                                                                     │
│  ❌ Fehleranfällig  ❌ Nicht skalierbar  ❌ Kein Audit-Trail       │
└─────────────────────────────────────────────────────────────────────┘

Die digitale Lösung

┌─────────────────────────────────────────────────────────────────────┐
│                   DIGITALE FÜHRERSCHEINKONTROLLE                    │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. System sendet automatisch Erinnerung an Fahrer                 │
│  2. Fahrer scannt Sicherheitssiegel mit Smartphone                 │
│  3. System erfasst: Wer, Wann, Wo (GPS), Foto optional             │
│  4. Eskalation bei Nicht-Kontrolle an Vorgesetzten                 │
│  5. Revisionssicheres Archiv mit Suchfunktion                      │
│                                                                     │
│  ✅ Automatisiert  ✅ Skalierbar  ✅ Revisionssicher dokumentiert  │
└─────────────────────────────────────────────────────────────────────┘

Architektur eines Führerscheinkontroll-Systems

Komponenten-Übersicht

┌─────────────────────────────────────────────────────────────────────────┐
│                         FÜHRERSCHEINKONTROLL-SYSTEM                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌───────────────┐    ┌───────────────┐    ┌───────────────┐           │
│  │  FAHRER-APP   │    │    BACKEND    │    │   DASHBOARD   │           │
│  │               │    │               │    │               │           │
│  │  QR/NFC-Scan  │───▶│  Validierung  │◀───│  Übersicht    │           │
│  │  GPS-Position │    │  Intervalle   │    │  Eskalation   │           │
│  │  Foto-Upload  │    │  Erinnerungen │    │  Reports      │           │
│  └───────────────┘    └───────────────┘    └───────────────┘           │
│                              │                                          │
│                              ▼                                          │
│                    ┌───────────────────┐                               │
│                    │  SICHERHEITSSIEGEL │                               │
│                    │                   │                               │
│                    │  Hologramm + QR   │                               │
│                    │  Auf Führerschein │                               │
│                    │  Manipulations-   │                               │
│                    │  schutz           │                               │
│                    └───────────────────┘                               │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Das Sicherheitssiegel-Konzept

Der Clou: Ein holografisches Siegel auf dem Führerschein, das nur vor Ort gescannt werden kann.

Warum das wichtig ist:

  • Physische Anwesenheit beweisen: Der Fahrer muss den echten Führerschein in der Hand haben
  • Manipulationsschutz: Hologramme sind schwer zu fälschen
  • Eindeutige Zuordnung: Jedes Siegel hat eine einmalige ID
<?php

declare(strict_types=1);

class SealService
{
    public function __construct(
        private readonly PDO $db
    ) {}

    /**
     * Siegel validieren - simpel: Token hashen, DB lookup, fertig.
     */
    public function verify(string $scannedToken, string $tenantId): SealVerificationResult
    {
        // Token hashen (auf Siegel steht Klartext, in DB liegt Hash)
        $tokenHash = hash('sha256', $scannedToken);

        // DB lookup mit Tenant-Prüfung
        $stmt = $this->db->prepare(
            'SELECT s.id, s.driver_id, s.revoked_at
             FROM security_seals s
             WHERE s.tenant_id = ?
             AND s.token_hash = ?'
        );
        $stmt->execute([$tenantId, $tokenHash]);
        $seal = $stmt->fetch();

        if ($seal === false) {
            return SealVerificationResult::invalid('Siegel nicht gefunden');
        }

        if ($seal['revoked_at'] !== null) {
            return SealVerificationResult::invalid('Siegel wurde gesperrt');
        }

        return SealVerificationResult::valid($seal['driver_id']);
    }

    /**
     * Neues Siegel erstellen
     */
    public function createSeal(string $tenantId, string $driverId): string
    {
        // Zufälliges Token generieren (16 Zeichen Base32, gut lesbar)
        $token = $this->generateToken();
        $tokenHash = hash('sha256', $token);

        // Altes Siegel des Fahrers revoken
        $this->db->prepare(
            'UPDATE security_seals
             SET revoked_at = NOW(), revoked_reason = ?
             WHERE tenant_id = ? AND driver_id = ? AND revoked_at IS NULL'
        )->execute(['Neues Siegel erstellt', $tenantId, $driverId]);

        // Neues Siegel anlegen
        $this->db->prepare(
            'INSERT INTO security_seals (tenant_id, token_hash, driver_id)
             VALUES (?, ?, ?)'
        )->execute([$tenantId, $tokenHash, $driverId]);

        // Token zurückgeben (wird auf Siegel gedruckt)
        return $token;
    }

    private function generateToken(): string
    {
        // Base32-Alphabet (ohne 0/O/1/I für Lesbarkeit)
        $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
        $token = '';
        for ($i = 0; $i < 16; $i++) {
            $token .= $chars[random_int(0, strlen($chars) - 1)];
        }
        return $token;
    }
}

Warum so simpel? Auf dem Aufkleber steht ein zufälliges Token (z.B. ABCD-EFGH-JKLM-NPQR). In der Datenbank liegt nur der Hash davon. Bei Scan: Token hashen, DB lookup, fertig. Kein Krypto-Overhead, leicht verständlich, reicht für den Use Case.

Multi-Tenant-Regel: Jede Query auf security_seals und license_checks immer mit tenant_id filtern - niemals nur über id. Sonst kann bei Bugs “falscher Tenant” sichtbar werden.


Datenmodell

PostgreSQL-Schema

-- Mandanten (Unternehmen)
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    settings JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Fahrer
CREATE TABLE drivers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    employee_number VARCHAR(50),
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    email VARCHAR(255),
    phone VARCHAR(50),

    -- Führerscheindaten
    license_number VARCHAR(50) NOT NULL,
    license_classes VARCHAR(50)[], -- {'B', 'BE', 'C', 'CE'}
    license_valid_until DATE,

    -- Kontrollintervall (überschreibt Tenant-Default)
    check_interval_days INTEGER DEFAULT 180, -- 6 Monate

    -- Materialisiert für Scheduler-Performance (Trigger pflegt das)
    next_check_due DATE,

    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),

    UNIQUE(tenant_id, license_number)
);

CREATE INDEX idx_drivers_tenant ON drivers(tenant_id) WHERE is_active = true;
CREATE INDEX idx_drivers_next_check ON drivers(tenant_id, next_check_due)
    WHERE is_active = true;

-- Sicherheitssiegel
CREATE TABLE security_seals (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    token_hash VARCHAR(64) NOT NULL, -- SHA256 des Tokens (Token selbst nicht speichern!)
    driver_id UUID NOT NULL REFERENCES drivers(id),

    issued_at TIMESTAMPTZ DEFAULT NOW(),
    issued_by UUID, -- User der das Siegel ausgegeben hat

    revoked_at TIMESTAMPTZ,
    revoked_reason VARCHAR(255),

    UNIQUE(tenant_id, token_hash)
);

-- Nur ein aktives Siegel pro Fahrer (mit tenant_id für Multi-Tenant-Sicherheit)
CREATE UNIQUE INDEX idx_seals_active_driver
    ON security_seals(tenant_id, driver_id)
    WHERE revoked_at IS NULL;

-- Kontrollen (die wichtigste Tabelle)
CREATE TABLE license_checks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    driver_id UUID NOT NULL REFERENCES drivers(id),
    seal_id UUID REFERENCES security_seals(id),

    checked_at TIMESTAMPTZ NOT NULL,
    checked_by_user UUID, -- Bei manueller Kontrolle

    -- Scan-Details
    scan_method VARCHAR(20) NOT NULL, -- 'qr', 'nfc', 'manual'
    device_id VARCHAR(100),
    app_version VARCHAR(20),

    -- GPS (optional, aber empfohlen)
    latitude DECIMAL(10, 8),
    longitude DECIMAL(11, 8),
    location_accuracy_meters INTEGER,

    -- Ergebnis
    result VARCHAR(20) NOT NULL, -- 'valid', 'expired', 'invalid', 'manual_ok'
    result_details JSONB,

    -- Foto des Führerscheins (optional)
    photo_stored BOOLEAN DEFAULT false,
    photo_hash VARCHAR(64), -- SHA256 für Integrität

    created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY RANGE (checked_at);

-- Partitionen pro Jahr
CREATE TABLE license_checks_2024 PARTITION OF license_checks
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE license_checks_2025 PARTITION OF license_checks
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

-- Index für schnelle Abfragen (auf Parent = automatisch auf Partitionen, ab PG11)
-- Bei älteren Versionen oder manueller Partitionierung: pro Partition anlegen
CREATE INDEX idx_checks_driver_time ON license_checks(driver_id, checked_at DESC);
CREATE INDEX idx_checks_tenant_time ON license_checks(tenant_id, checked_at DESC);

-- Trigger: next_check_due automatisch aktualisieren
CREATE OR REPLACE FUNCTION update_next_check_due() RETURNS trigger AS $$
BEGIN
    UPDATE drivers
    SET next_check_due = NEW.checked_at::date + check_interval_days
    WHERE id = NEW.driver_id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_next_check_due
    AFTER INSERT ON license_checks
    FOR EACH ROW
    EXECUTE FUNCTION update_next_check_due();

-- Bei Änderung des Intervalls: next_check_due neu berechnen
CREATE OR REPLACE FUNCTION recalc_next_check_due() RETURNS trigger AS $$
BEGIN
    IF NEW.check_interval_days <> OLD.check_interval_days THEN
        -- Aus letztem Check neu berechnen (mit tenant_id für Partition-Pruning)
        SELECT lc.checked_at::date + NEW.check_interval_days
        INTO NEW.next_check_due
        FROM license_checks lc
        WHERE lc.tenant_id = NEW.tenant_id
          AND lc.driver_id = NEW.id
        ORDER BY lc.checked_at DESC
        LIMIT 1;

        -- Fallback: kein Check vorhanden = sofort fällig
        IF NEW.next_check_due IS NULL THEN
            NEW.next_check_due := CURRENT_DATE;
        END IF;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_recalc_next_check_due
    BEFORE UPDATE OF check_interval_days ON drivers
    FOR EACH ROW
    EXECUTE FUNCTION recalc_next_check_due();

Intervall-Management und Erinnerungen

Der Scheduler

<?php

declare(strict_types=1);

class LicenseCheckScheduler
{
    public function __construct(
        private readonly DriverRepository $drivers,
        private readonly NotificationService $notifications,
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Täglich per Cron ausführen
     */
    public function processUpcomingChecks(string $tenantId): SchedulerResult
    {
        $stats = ['reminded' => 0, 'escalated' => 0, 'blocked' => 0];

        // Fahrer mit next_check_due in den nächsten 14 Tagen
        // Nutzt Index: idx_drivers_next_check(tenant_id, next_check_due)
        $upcomingDrivers = $this->drivers->findWithUpcomingCheck(
            tenantId: $tenantId,
            daysAhead: 14
        );

        foreach ($upcomingDrivers as $driver) {
            $daysUntilDue = $this->calculateDaysUntilDue($driver);

            if ($daysUntilDue <= 0) {
                // Überfällig!
                $this->handleOverdue($driver);
                $stats['escalated']++;
            } elseif ($daysUntilDue <= 3) {
                // Dringend
                $this->sendUrgentReminder($driver);
                $stats['reminded']++;
            } elseif ($daysUntilDue <= 7) {
                // Normal
                $this->sendReminder($driver);
                $stats['reminded']++;
            } elseif ($daysUntilDue <= 14) {
                // Erste Erinnerung
                $this->sendFirstReminder($driver);
                $stats['reminded']++;
            }
        }

        return new SchedulerResult($stats);
    }

    private function calculateDaysUntilDue(Driver $driver): int
    {
        // next_check_due ist materialisiert (Trigger pflegt es)
        if ($driver->nextCheckDue === null) {
            // Noch nie kontrolliert = sofort fällig
            return 0;
        }

        $now = new DateTimeImmutable('today');
        return (int) $now->diff($driver->nextCheckDue)->format('%r%a');
    }

    private function handleOverdue(Driver $driver): void
    {
        $daysPastDue = abs($this->calculateDaysUntilDue($driver));

        // Eskalationsstufen
        if ($daysPastDue >= 14) {
            // Stufe 3: Fahrzeugnutzung sperren
            $this->blockVehicleUsage($driver);
            $this->notifyManagement($driver, EscalationLevel::Critical);

            $this->logger->critical('Führerscheinkontrolle kritisch überfällig', [
                'driver_id' => $driver->id,
                'days_overdue' => $daysPastDue
            ]);

        } elseif ($daysPastDue >= 7) {
            // Stufe 2: Vorgesetzten informieren
            $this->notifications->notifySupervisor($driver);
            $this->notifications->sendUrgent($driver,
                'Führerscheinkontrolle überfällig - bitte umgehend durchführen!'
            );

        } else {
            // Stufe 1: Tägliche Erinnerung
            $this->notifications->sendDaily($driver,
                'Ihre Führerscheinkontrolle ist überfällig.'
            );
        }
    }

    private function blockVehicleUsage(Driver $driver): void
    {
        // Integration mit Fuhrpark-System
        $this->drivers->setVehicleAccessBlocked($driver->id, true);

        // Wenn Fahrzeugschlüssel-System vorhanden
        // $this->keySystem->revokeAccess($driver->employeeNumber);
    }
}

Erinnerungs-Templates

<?php

class NotificationTemplates
{
    public static function firstReminder(Driver $driver, int $daysRemaining): string
    {
        return <<<TEXT
        Hallo {$driver->firstName},

        Ihre Führerscheinkontrolle ist in {$daysRemaining} Tagen fällig.

        So führen Sie die Kontrolle durch:
        1. Öffnen Sie die Führerscheinkontroll-App
        2. Scannen Sie das Siegel auf Ihrem Führerschein
        3. Fertig!

        Bei Fragen wenden Sie sich an Ihren Fuhrparkverantwortlichen.

        Viele Grüße
        Ihr Fuhrpark-Team
        TEXT;
    }

    public static function urgentReminder(Driver $driver): string
    {
        return <<<TEXT
        ⚠️ DRINGEND: Führerscheinkontrolle erforderlich

        Hallo {$driver->firstName},

        Ihre Führerscheinkontrolle ist in weniger als 3 Tagen fällig!

        Bitte führen Sie die Kontrolle HEUTE durch, um eine
        Sperrung Ihrer Fahrzeugnutzung zu vermeiden.

        TEXT;
    }

    public static function supervisorEscalation(
        Driver $driver,
        int $daysOverdue
    ): string {
        return <<<TEXT
        🚨 Eskalation: Überfällige Führerscheinkontrolle

        Mitarbeiter: {$driver->firstName} {$driver->lastName}
        Personal-Nr.: {$driver->employeeNumber}
        Überfällig seit: {$daysOverdue} Tagen

        Der Mitarbeiter hat trotz mehrfacher Erinnerung keine
        Führerscheinkontrolle durchgeführt.

        Bitte ergreifen Sie geeignete Maßnahmen.

        TEXT;
    }
}

Der Scan-Prozess

App-seitiger Flow (vereinfacht)

// React Native / Flutter - vereinfachter Code

interface ScanResult {
  sealToken: string;
  timestamp: string;
  location?: {
    latitude: number;
    longitude: number;
    accuracy: number;
  };
  deviceId: string;
}

async function performLicenseCheck(): Promise<CheckResult> {
  // 1. GPS-Position erfassen (falls erlaubt)
  const location = await getCurrentLocation();

  // 2. QR-Code scannen
  const scanResult = await scanQRCode();

  if (!scanResult.success) {
    return { success: false, error: 'Scan fehlgeschlagen' };
  }

  // 3. An Backend senden
  const response = await api.post('/license-checks', {
    sealToken: scanResult.data,
    scannedAt: new Date().toISOString(),
    location: location,
    deviceId: getDeviceId(),
    appVersion: APP_VERSION,
  });

  // 4. Ergebnis anzeigen
  if (response.result === 'valid') {
    showSuccess('Führerscheinkontrolle erfolgreich!');
    showNextCheckDate(response.nextCheckDue);
  } else {
    showError(response.message);
    // Bei Fehler: Möglichkeit zur manuellen Kontrolle anbieten
  }

  return response;
}

Backend-Validierung

<?php

declare(strict_types=1);

class LicenseCheckController
{
    public function __construct(
        private readonly SealRepository $seals,
        private readonly DriverRepository $drivers,
        private readonly CheckRepository $checks,
        private readonly AuditLogger $audit
    ) {}

    public function performCheck(Request $request): JsonResponse
    {
        $tenantId = $request->user()->tenantId; // Aus Auth-Context

        $data = $request->validate([
            'sealToken' => 'required|string|max:100',
            'scannedAt' => 'required|date',
            'location' => 'nullable|array',
            'location.latitude' => 'nullable|numeric|between:-90,90',
            'location.longitude' => 'nullable|numeric|between:-180,180',
            'deviceId' => 'required|string|max:100',
            'appVersion' => 'required|string|max:20',
        ]);

        // 1. Siegel finden und validieren (hasht Token intern, sucht mit tenant_id)
        $seal = $this->seals->findByToken($tenantId, $data['sealToken']);

        if ($seal === null) {
            $this->audit->log('unknown_seal_scanned', $data);
            return $this->errorResponse('Unbekanntes Siegel', 'SEAL_NOT_FOUND');
        }

        if ($seal->isRevoked()) {
            $this->audit->log('revoked_seal_scanned', [
                'seal_id' => $seal->id,
                'revoked_at' => $seal->revokedAt,
            ]);
            return $this->errorResponse(
                'Siegel wurde gesperrt. Bitte wenden Sie sich an den Fuhrpark.',
                'SEAL_REVOKED'
            );
        }

        // 2. Fahrer ermitteln
        $driver = $this->drivers->find($seal->driverId);

        if (!$driver->isActive) {
            return $this->errorResponse(
                'Fahrer ist nicht mehr aktiv',
                'DRIVER_INACTIVE'
            );
        }

        // 3. Führerschein-Gültigkeit prüfen
        $licenseStatus = $this->checkLicenseValidity($driver);

        // 4. Scan-Zeit validieren (Anti-Manipulation)
        // Client kann max. 24h in die Vergangenheit scannen (Offline-Toleranz)
        $scannedAt = new DateTimeImmutable($data['scannedAt']);
        $now = new DateTimeImmutable();
        $maxBackdate = $now->modify('-24 hours');

        if ($scannedAt > $now) {
            $scannedAt = $now; // Zukunft → Server-Zeit
        } elseif ($scannedAt < $maxBackdate) {
            $scannedAt = $maxBackdate; // Zu weit zurück → Cap
        }

        // 5. Kontrolle speichern
        $check = new LicenseCheck(
            tenantId: $driver->tenantId,
            driverId: $driver->id,
            sealId: $seal->id,
            checkedAt: $scannedAt,
            scanMethod: 'qr',
            deviceId: $data['deviceId'],
            appVersion: $data['appVersion'],
            latitude: $data['location']['latitude'] ?? null,
            longitude: $data['location']['longitude'] ?? null,
            result: $licenseStatus->result,
            resultDetails: $licenseStatus->details,
        );

        $this->checks->save($check);

        // 6. Audit-Log
        $this->audit->log('license_check_performed', [
            'check_id' => $check->id,
            'driver_id' => $driver->id,
            'result' => $licenseStatus->result,
        ]);

        // 7. Nächsten Termin (Trigger hat ihn schon berechnet, hier nur Response)
        // Wichtig bei Offline-Scan/Backdating: Intervall startet ab Kontrolle
        $nextCheckDue = $check->checkedAt
            ->modify("+{$driver->checkIntervalDays} days");

        return response()->json([
            'success' => true,
            'result' => $licenseStatus->result,
            'message' => $licenseStatus->message,
            'nextCheckDue' => $nextCheckDue->format('Y-m-d'),
            'driverName' => $driver->firstName . ' ' . $driver->lastName,
        ]);
    }

    private function checkLicenseValidity(Driver $driver): LicenseStatus
    {
        // Führerschein abgelaufen?
        if ($driver->licenseValidUntil !== null) {
            $today = new DateTimeImmutable('today');

            if ($driver->licenseValidUntil < $today) {
                return new LicenseStatus(
                    result: 'expired',
                    message: 'Führerschein abgelaufen am ' .
                             $driver->licenseValidUntil->format('d.m.Y'),
                    details: ['expired_at' => $driver->licenseValidUntil]
                );
            }

            // Läuft bald ab? (Warnung)
            $warningDate = $today->modify('+30 days');
            if ($driver->licenseValidUntil < $warningDate) {
                return new LicenseStatus(
                    result: 'valid',
                    message: 'Kontrolle erfolgreich. Hinweis: Führerschein läuft am ' .
                             $driver->licenseValidUntil->format('d.m.Y') . ' ab.',
                    details: ['expires_soon' => true]
                );
            }
        }

        return new LicenseStatus(
            result: 'valid',
            message: 'Führerscheinkontrolle erfolgreich durchgeführt.',
            details: []
        );
    }
}

DSGVO-Aspekte

Welche Daten werden verarbeitet?

DatenkategorieRechtsgrundlageSpeicherdauer
Name, Personal-Nr.Arbeitsvertrag (Art. 6 Abs. 1b)Dauer des Arbeitsverhältnisses + 3 Jahre
FührerscheinnummerBerechtigtes Interesse (Art. 6 Abs. 1f)Wie oben
KontrolldatenRechtliche Verpflichtung (Art. 6 Abs. 1c)Nach interner Löschfrist / Aufbewahrungskonzept
GPS-PositionJe nach DSB-Bewertung: Opt-in oder berechtigtes InteresseMit Kontrolldaten

GPS-Erfassung: Standard ist: optional und minimiert. Ob Einwilligung oder berechtigtes Interesse greift, hängt von Betriebsvereinbarung und DSB-Bewertung ab. Im Zweifel: Opt-in mit klarer Information.

Foto des Führerscheins: In vielen Unternehmen tabu - zu viele DSGVO-Fallstricke. Standard ist: kein Foto, nur Scan-Event + Siegel-Validierung + Audit-Trail. Das reicht für den Nachweis.

Datensparsamkeit umsetzen

<?php

class GdprCompliantStorage
{
    /**
     * GPS nur speichern wenn wirklich nötig
     */
    public function shouldStoreLocation(Tenant $tenant, Driver $driver): bool
    {
        // Tenant-Setting prüfen
        if (!$tenant->getSetting('store_check_location', false)) {
            return false;
        }

        // Fahrer hat Widerspruch eingelegt?
        if ($driver->hasOptedOutOfLocationTracking()) {
            return false;
        }

        return true;
    }

    /**
     * Foto-Hash statt Foto speichern
     */
    public function processLicensePhoto(
        string $photoData,
        LicenseCheck $check
    ): void {
        // Foto nur temporär zur Validierung nutzen
        $hash = hash('sha256', $photoData);

        // Optional: KI-Validierung (Führerschein erkannt?)
        $isValidLicense = $this->validateLicenseImage($photoData);

        // Nur Hash speichern, nicht das Foto selbst
        $check->photoHash = $hash;
        $check->photoValidated = $isValidLicense;

        // Foto NICHT speichern (Datensparsamkeit)
        // Wenn doch nötig: verschlüsselt und zeitlich begrenzt
    }

    /**
     * Automatische Anonymisierung nach Aufbewahrungsfrist
     * driver_id bleibt (FK NOT NULL), nur Metadaten werden gelöscht
     */
    public function cleanupExpiredData(Tenant $tenant): int
    {
        $retentionYears = $tenant->getSetting('retention_years', 10);
        $cutoffDate = (new DateTimeImmutable())
            ->modify("-{$retentionYears} years");

        // Metadaten anonymisieren, FK bleibt intakt
        $count = $this->db->execute(
            "UPDATE license_checks
             SET latitude = NULL,
                 longitude = NULL,
                 device_id = NULL,
                 photo_hash = NULL
             WHERE tenant_id = ?
             AND checked_at < ?
             AND (latitude IS NOT NULL
                  OR longitude IS NOT NULL
                  OR device_id IS NOT NULL
                  OR photo_hash IS NOT NULL)",
            [$tenant->id, $cutoffDate]
        );

        return $count;
    }
}

Dashboard für Fuhrparkleiter

Statusübersicht

<?php

class DashboardService
{
    public function getComplianceOverview(string $tenantId): ComplianceOverview
    {
        // Nutzt materialisierte next_check_due - kein LATERAL nötig
        $stats = $this->db->query(
            "SELECT
                COUNT(*) FILTER (WHERE status = 'ok') as compliant,
                COUNT(*) FILTER (WHERE status = 'due_soon') as due_soon,
                COUNT(*) FILTER (WHERE status = 'overdue') as overdue,
                COUNT(*) FILTER (WHERE status = 'blocked') as blocked,
                COUNT(*) as total
             FROM (
                SELECT
                    d.id,
                    CASE
                        WHEN d.vehicle_access_blocked THEN 'blocked'
                        WHEN d.next_check_due IS NULL THEN 'overdue'
                        WHEN d.next_check_due < CURRENT_DATE THEN 'overdue'
                        WHEN d.next_check_due < CURRENT_DATE + 14 THEN 'due_soon'
                        ELSE 'ok'
                    END as status
                FROM drivers d
                WHERE d.tenant_id = ?
                AND d.is_active = true
             ) sub",
            [$tenantId]
        );

        return new ComplianceOverview(
            compliant: $stats['compliant'],
            dueSoon: $stats['due_soon'],
            overdue: $stats['overdue'],
            blocked: $stats['blocked'],
            total: $stats['total'],
            complianceRate: $stats['total'] > 0
                ? round($stats['compliant'] / $stats['total'] * 100, 1)
                : 100.0
        );
    }
}

Export für Audits

<?php

class AuditExportService
{
    /**
     * Export für Versicherung/Behörden
     */
    public function generateAuditReport(
        string $tenantId,
        DateTimeImmutable $from,
        DateTimeImmutable $to
    ): AuditReport {
        $checks = $this->db->query(
            "SELECT
                d.employee_number,
                d.first_name,
                d.last_name,
                d.license_number,
                d.license_classes,
                lc.checked_at,
                lc.scan_method,
                lc.result,
                lc.latitude,
                lc.longitude
             FROM license_checks lc
             JOIN drivers d ON d.id = lc.driver_id
             WHERE lc.tenant_id = ?
             AND lc.checked_at BETWEEN ? AND ?
             ORDER BY lc.checked_at DESC",
            [$tenantId, $from, $to]
        );

        return new AuditReport(
            generatedAt: new DateTimeImmutable(),
            periodFrom: $from,
            periodTo: $to,
            checks: $checks,
            summary: $this->generateSummary($checks),
            // Digitale Signatur für Integrität
            signature: $this->signReport($checks)
        );
    }

    private function signReport(array $checks): string
    {
        $data = json_encode($checks);
        return hash_hmac('sha256', $data, $this->reportSigningKey);
    }
}

Checkliste: Führerscheinkontroll-System entwickeln

MVP (Phase 1):

  • Fahrer- und Siegel-Verwaltung
  • QR-Code-Scan in App
  • Basis-Validierung (Siegel gültig?)
  • Kontrolle speichern mit Timestamp
  • Einfache Übersicht für Fuhrparkleiter

Erweiterung (Phase 2):

  • Automatische Erinnerungen (Email/Push)
  • Eskalationsprozess
  • GPS-Erfassung (optional)
  • Dashboard mit Compliance-Rate
  • PDF-Export für Audits

Enterprise (Phase 3):

  • NFC-Siegel-Support
  • Integration Fuhrpark-Software
  • Fahrzeugsperre bei Nicht-Kontrolle
  • Multi-Tenant / White-Label
  • API für Drittsysteme

Typische Fehler vermeiden

FehlerBesser
Nur manuelle KontrolleSiegel für physische Anwesenheit
Keine EskalationMehrstufig: Erinnerung → Vorgesetzter → Sperre
GPS immer speichernOpt-in mit klarer Begründung
Führerschein-Foto dauerhaftHash speichern, Foto löschen
Excel als “System”Echte Datenbank mit Audit-Trail
Feste 6-Monats-IntervalleKonfigurierbar pro Fahrer/Fahrzeugklasse

Fazit

Digitale Führerscheinkontrolle ist kein Luxus, sondern notwendige Absicherung. Die Haftungsrisiken bei mangelhafter Dokumentation sind erheblich - im schlimmsten Fall persönliche Strafe für den Verantwortlichen.

Die wichtigsten Punkte:

  1. Physische Anwesenheit sicherstellen - Siegel auf dem Führerschein statt Selbstauskunft
  2. Automatisierung - Erinnerungen und Eskalation ohne manuellen Aufwand
  3. Revisionssicherheit - Lückenlose Dokumentation mit Timestamps
  4. DSGVO-Konformität - Datensparsamkeit, klare Löschfristen
  5. Skalierbarkeit - Von 10 bis 10.000 Fahrer ohne Mehraufwand

Die Technik ist überschaubar. Die Herausforderung liegt in der Akzeptanz: Fahrer müssen das System nutzen. Deshalb: So einfach wie möglich machen. Ein Scan, fertig. Keine komplizierten Formulare, keine Desktop-Pflicht.

Ein gut implementiertes System spart nicht nur Verwaltungsaufwand - es kann im Ernstfall vor persönlicher Haftung schützen.

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

Wächter-Kontrollsysteme: Security-Apps für Sicherheitsdienste

Wächter-Kontrollsysteme für Sicherheitsdienste: RFID, GPS und Offline-Betrieb in einer App, mit Erfahrungen aus Millionen …

Workflow-Automatisierung: Freigabeprozesse und Genehmigungen

Workflow-Automatisierung mit PHP: digitale Freigabe- und Genehmigungsprozesse abbilden, mit Vertretungsregeln, Fristen und Nachweis.

Audit-Trails & Revisionssicherheit: Wer hat wann was geändert?

Audit-Trails in PHP implementieren: Change-Tracking, Event-Sourcing-Patterns, GoBD-konforme Logs.