PHP-Entwickler in Münster

PHP-Hintergrundjobs für Unternehmen in Münster von einer Person: Cron, Laravel-Scheduler oder Symfony Scheduler, idempotente Importe, Sperren gegen doppelte Läufe, Wiederholungen und Kontrolle, dass ein Job wirklich lief. Schriftliche Erstschätzung kostenlos.

PHP-Entwickler

Mein Platz in der WordPress-Community

  • Mein wordpress.org-Konto besteht seit 2006.
  • Ich habe zum WordPress-Core in den Versionen 7.0 und 7.1 beigetragen.
  • Ich betreue 30 Plugins auf wordpress.org.
  • Ich organisiere WordCamps und trete dort als Speaker auf.
  • Ich war bei jedem WordCamp Europe von 2017 bis 2026 dabei.

Mein WordPress.org-Profil

Wer in Münster einen PHP-Entwickler sucht, hat oft ein Problem, das auf keiner Oberfläche zu sehen ist: Ein nächtlicher Import läuft zweimal, ein Bericht kommt nicht mehr an, oder eine Aufgabe bleibt nach einem Neustart des Servers liegen, und niemand merkt es. Diese Seite behandelt ein Thema: geplante Aufgaben und Hintergrundjobs in PHP-Anwendungen, mit Cron, dem Laravel-Scheduler und Laravel-Queues oder Symfony Scheduler und Messenger.

Ich bin Mariusz Szatkowski, ein einzelner PHP-Entwickler aus Gdynia in Polen, und programmiere seit 2006. Ich arbeite ausschließlich remote und schriftlich, es gibt keine Agentur und keinen Projektleiter dazwischen. Die Beispiele habe ich unter PHP 8.4 ausgeführt, für diese Version nennt php.net Sicherheitsunterstützung bis zum 31. Dezember 2028 (Supported Versions).

Restaurants und Anleger am Aasee in Münster

Foto: Dietmar Rabich, CC BY-SA 4.0, Wikimedia Commons.

#Ein Münsteraner Beispiel: Messdaten der Stadt-Temperatur-Stationen

Die Stadt Münster betreibt ein Netz von Temperaturstationen, das Projekt Stadt Temperatur. Die Messdaten stehen im Open-Data-Portal der Stadt unter der Datenlizenz Deutschland Namensnennung 2.0. Das Portal bietet unter anderem eine CSV-Datei mit den Werten der letzten 24 Stunden in Zehn-Minuten-Schritten und Abfragen mit den Parametern date_from und date_to, jeweils als CSV oder JSON.

Das ist ein brauchbares Muster für jeden Import, den ein Unternehmen regelmäßig braucht: Daten von einer fremden Quelle holen, speichern, nichts doppelt anlegen. Ich habe für den 10. November 2026 von 10:00 bis 10:30 Uhr eine JSON-Abfrage gestellt. Sie lieferte 111 Zeilen: 37 Stationen mal drei Zeitstempel (10:00, 10:10 und 10:20). In 18 Zeilen fehlte der Temperaturwert, der Wert war null. Sechs Stationen lieferten in diesem Fenster also gar nichts, und die Quelle meldete das nicht als Fehler.

Daran lassen sich die vier Fragen dieser Seite ablesen:

  1. Was passiert, wenn der Job denselben Zeitraum ein zweites Mal holt?
  2. Was passiert, wenn zwei Läufe sich überschneiden?
  3. Was passiert nach einem Ausfall: Wer holt die verpassten Stunden nach?
  4. Woran merken Sie, dass etwas ausbleibt, obwohl kein Fehler auftritt?

#Cron, Laravel-Scheduler oder Symfony Scheduler

Cron startet einen Befehl zu einer Uhrzeit, mehr nicht. Die Laravel-Dokumentation beschreibt den Nachteil: Der Zeitplan steht nicht in der Versionsverwaltung, und neue Einträge setzt man per SSH auf dem Server. Laravel lagert deshalb den Zeitplan in die Anwendung aus. Auf dem Server steht nur ein Eintrag, der jede Minute php artisan schedule:run aufruft (Laravel: Task Scheduling). Symfony geht einen anderen Weg: Der Scheduler ist ein Messenger-Transport, und ein dauerhaft laufender Worker erzeugt die Nachrichten (Symfony: Scheduler).

FrageLaravelSymfony
Was startet die Aufgaben?Ein Cron-Eintrag pro Minute ruft schedule:run aufEin Worker mit messenger:consume scheduler_default, den Supervisor oder systemd am Leben hält
Wo steht der Zeitplan?In routes/console.phpIn einer Klasse mit dem Attribut #[AsSchedule]
Überschneidung verhindernwithoutOverlapping(), Sperre im Cachelock() am Schedule, gilt für den ganzen Zeitplan
Mehrere Server oder WorkeronOneServer(), nur mit Cache-Treiber database, memcached, dynamodb oder redisWeitere Worker warten als Reserve und übernehmen, wenn der aktive ausfällt
Nach einem AusfallIn der Dokumentation nicht beschrieben, der Job muss selbst nachholenstateful() merkt sich den letzten Lauf, processOnlyLastMissedRun() holt nur den letzten verpassten nach
Meldung bei Erfolg und FehleronSuccess, onFailure, pingOnSuccess, pingOnFailureEreignisse PreRunEvent, PostRunEvent, FailureEvent

Welche Variante passt, hängt weniger vom Geschmack ab als vom Projekt. In einer bestehenden Laravel-Anwendung nehme ich den Laravel-Scheduler, in einer Symfony-Anwendung den Symfony Scheduler. Eine Anwendung ohne Framework bekommt einen schmalen Konsolenbefehl und einen Cron-Eintrag, dazu die Sperre und die Kontrolle aus den folgenden Abschnitten.

#Idempotent: derselbe Lauf zweimal, dasselbe Ergebnis

Ein Job läuft irgendwann doppelt. Die Symfony-Dokumentation sagt es deutlich: Eine Nachricht kann unter normalen Bedingungen mehr als einmal zugestellt werden, etwa wenn ein Worker sie verarbeitet, aber vor der Bestätigung abstürzt. Wiederholungsmechanismen können eine doppelte Ausführung dann nicht verhindern (Symfony Messenger: Writing Idempotent Handlers).

Die Regel dort ist einfach: Einen absoluten Wert zu setzen ist sicher, einen Zähler zu verringern nicht. Für den Wetter-Import heißt das: Der Schlüssel einer Messung ist die Station zusammen mit dem Zeitstempel, und ein zweites Schreiben desselben Schlüssels ändert nichts.

function importReadings(PDO $pdo, array $rows): int
{
    $sql = 'INSERT INTO readings (device_id, measured_at, temperature, air_humidity)
            VALUES (:id, :ts, :t, :h)
            ON CONFLICT (device_id, measured_at)
            DO UPDATE SET temperature = excluded.temperature,
                          air_humidity = excluded.air_humidity';
    $stmt = $pdo->prepare($sql);

    $pdo->beginTransaction();
    foreach ($rows as $row) {
        $stmt->execute([
            'id' => $row['device_id'],
            'ts' => $row['measurement_timestamp'],
            't'  => $row['temperature'],
            'h'  => $row['air_humidity'],
        ]);
    }
    $pdo->commit();

    return count($rows);
}

Ich habe die Funktion gegen die 111 Zeilen aus der Abfrage ausgeführt, in SQLite mit dem Primärschlüssel aus Station und Zeitstempel. Nach dem ersten Lauf standen 111 Zeilen in der Tabelle, nach dem zweiten Lauf ebenfalls 111, davon 18 mit leerer Temperatur. Die Syntax ON CONFLICT gilt für SQLite und PostgreSQL. In Laravel übernimmt das die Methode upsert(): Das erste Argument sind die Werte, das zweite die Spalten, die einen Datensatz eindeutig bestimmen, das dritte die Spalten, die bei einem Treffer aktualisiert werden. Alle Datenbanken außer SQL Server verlangen dafür einen Primär- oder Unique-Index (Laravel: Eloquent, Upserts).

Für Vorgänge, die sich nicht als Upsert schreiben lassen, etwa eine Zahlung, empfiehlt Symfony einen stabilen Schlüssel in der Nachricht. Er wird aus dem Geschäftsvorfall abgeleitet, nicht beim Versenden erzeugt, und eine Unique-Bedingung in der Datenbank fängt gleichzeitige Zustellungen ab.

#Überschneidungen und Sperren

Laravel startet eine geplante Aufgabe standardmäßig auch dann, wenn der vorherige Lauf noch nicht beendet ist. Dagegen hilft withoutOverlapping(). Die Sperre liegt im Cache und läuft standardmäßig nach 24 Stunden ab, ein anderer Wert lässt sich in Minuten angeben. Bleibt eine Aufgabe nach einem Serverproblem hängen, löscht schedule:clear-cache die Sperren. Läuft der Scheduler auf mehreren Servern, startet jeder Server die Aufgabe, bis onOneServer() sie auf einen beschränkt.

use Illuminate\Support\Facades\Schedule;

Schedule::command('wetter:import')
    ->everyTenMinutes()
    ->withoutOverlapping(15)
    ->onOneServer()
    ->pingOnSuccess($erfolgsUrl)
    ->pingOnFailure($fehlerUrl);

Für Jobs in der Queue kennt Laravel zwei weitere Werkzeuge: ShouldBeUnique lässt nur eine Instanz eines Jobs gleichzeitig in der Warteschlange zu, die Middleware WithoutOverlapping verhindert, dass zwei Jobs dieselbe Ressource gleichzeitig ändern (Laravel: Queues).

In Symfony gilt die Sperre am Schedule für den gesamten Zeitplan, nicht je Aufgabe. Wichtig ist die Warnung der Dokumentation: Sie läuft standardmäßig nach 300 Sekunden ab. Dauert die Verarbeitung länger, kann ein zweiter Worker die abgelaufene Sperre übernehmen und Nachrichten erneut ausführen. Lange Aufgaben gehören deshalb an einen Messenger-Transport, damit der Scheduler-Worker sie nur anstößt.

#[AsSchedule('wetter')]
class WetterSchedule implements ScheduleProviderInterface
{
    public function __construct(
        private CacheInterface $cache,
        private LockFactory $lockFactory,
    ) {}

    public function getSchedule(): Schedule
    {
        return new Schedule()
            ->with(RecurringMessage::cron('*/10 * * * *', new ImportStadtTemperatur()))
            ->stateful($this->cache)
            ->processOnlyLastMissedRun(true)
            ->lock($this->lockFactory->createLock('wetter-schedule'));
    }
}

Beides sind Sperren auf Zeit. Deshalb ist der Schlüssel aus dem vorigen Abschnitt kein Ersatz, sondern die zweite Absicherung: Fällt die Sperre aus, schreibt der Job trotzdem nichts doppelt.

#Nach einem Ausfall nachholen

Fällt der Worker oder der Server aus, entstehen Lücken. Die Symfony-Dokumentation beschreibt das genau: Läuft ein Worker nicht, erzeugt der Scheduler-Transport keine Nachrichten, und beim Neustart rechnet er ab diesem Zeitpunkt neu. Mit stateful() und dem Cache merkt er sich den letzten Lauf, processOnlyLastMissedRun(true) holt dann nur den letzten verpassten Lauf nach statt aller.

Für Importe mit einem Zeitfenster ist das oft die falsche Frage. Die Münsteraner Quelle nimmt date_from und date_to, also muss der Job nicht wissen, wie viele Läufe ausgefallen sind. Er speichert den Zeitstempel der letzten erfolgreich geschriebenen Messung und fragt beim nächsten Lauf ab dort bis jetzt ab. Eine Lücke von drei Stunden füllt sich dann von selbst, und dank des Upserts schadet jede Überlappung des Fensters nicht. Laravel beschreibt kein Nachholen, dort liegt es beim Job selbst.

#Wiederholen und fehlgeschlagene Jobs

Schlägt ein Job fehl, soll er nicht ohne Pause in Dauerschleife laufen und auch nicht stillschweigend verschwinden. Symfony Messenger wiederholt eine Nachricht standardmäßig dreimal mit Verzögerung. Die Strategie je Transport stellen Sie über max_retries, delay, multiplier, max_delay und jitter ein. Wirft ein Handler die UnrecoverableMessageHandlingException, wird die Nachricht nicht wiederholt. Ist ein Fehler dauerhaft, etwa ein gelöschter Datensatz, gehört er genau dorthin. Mit failure_transport landet die Nachricht danach in einer eigenen Warteschlange, und messenger:failed:show, messenger:failed:retry und messenger:failed:remove zeigen, wiederholen oder löschen sie.

Laravel legt Jobs, die ihre Versuche aufgebraucht haben, in der Tabelle failed_jobs ab. Die Versuche legen Sie mit --tries am Worker oder mit $tries am Job fest, die Pause mit --backoff. queue:failed listet die Jobs, queue:retry stößt sie erneut an, queue:forget und queue:flush löschen sie. Ohne Angabe wird ein Job nur einmal versucht.

Beide Frameworks teilen dieselbe Betriebsregel: Worker laufen lange und kennen neuen Code erst nach einem Neustart. Symfony empfiehlt messenger:stop-workers, Laravel queue:restart, jeweils im Deployment, nachdem der neue Code auf dem Server liegt.

#Woran Sie merken, dass ein Job ausbleibt

Ein Job, der nicht startet, wirft keinen Fehler. Alarm im Fehlerfall reicht deshalb nicht, es braucht auch einen Alarm für das Ausbleiben. Das Muster dafür steht in der Laravel-Dokumentation: pingOnSuccess() und pingOnFailure() rufen nach dem Lauf eine URL auf, etwa die eines Überwachungsdienstes. Der Dienst schlägt an, wenn der Aufruf innerhalb des erwarteten Zeitraums fehlt. In Symfony erledigt das ein Listener auf PostRunEvent und FailureEvent, und php bin/console debug:scheduler zeigt, welche Läufe wann anstehen.

Der Exit-Code allein genügt nicht, das zeigt das Münsteraner Beispiel: Die Abfrage lief fehlerfrei, und 18 von 111 Zeilen waren leer. Ich lasse den Job deshalb zusätzlich zählen, wie viele Zeilen er geschrieben hat und wie viele davon leer waren, und melde das mit dem Erfolg. Bleibt die Zahl der Stationen auf einmal weit unter dem Üblichen, ist das ein Befund, auch wenn der Job technisch grün war.

#Datenschutz: Was in der Fehlertabelle liegt

Ein fehlgeschlagener Job trägt seine Daten mit sich. In Symfony liegt die Nachricht im Failure Transport, in Laravel der Job in failed_jobs. Enthält die Nachricht einen Namen, eine Adresse oder eine Bestellung, liegen diese Daten in der Fehlerablage, solange niemand sie löscht. Nach Art. 5 Abs. 1 Buchst. e DSGVO dürfen Daten nur so lange in identifizierbarer Form gespeichert werden, wie es für den Zweck erforderlich ist (DSGVO im Amtsblatt der EU). Eine Fehlerablage, die nie geleert wird, passt dazu nicht.

Dafür gibt es technische Werkzeuge: Laravel löscht alte Einträge mit queue:prune-failed, bei Symfony entfernt messenger:failed:remove einzelne Nachrichten oder alle. Wie lange Sie Fehlerfälle aufbewahren, entscheidet Ihre Datenschutzberatung, nicht ich. Für Unternehmen in Münster ist die Aufsichtsbehörde die Landesbeauftragte für Datenschutz und Informationsfreiheit Nordrhein-Westfalen. Die Wetterdaten im Beispiel enthalten keine Personendaten.

#Checkliste für geplante Jobs

PrüfpunktFrage
IdempotenzSchreibt ein zweiter Lauf mit denselben Daten dasselbe Ergebnis, gesichert durch einen eindeutigen Schlüssel in der Datenbank?
ÜberschneidungGibt es eine Sperre, und was passiert, wenn sie vor dem Ende des Laufs abläuft?
Mehrere ServerLäuft der Job auf genau einem Server, und ist der Cache dafür gemeinsam?
NachholenHolt der Job Lücken selbst nach, etwa über den Zeitstempel der letzten erfolgreichen Zeile?
WiederholungSind Anzahl und Pausen der Versuche gesetzt, und gibt es dauerhafte Fehler, die nicht wiederholt werden?
FehlerablageSchaut jemand regelmäßig hinein, und wer löscht alte Einträge?
ÜberwachungSchlägt eine zweite Stelle an, wenn die Erfolgsmeldung ausbleibt?
DeploymentWerden Worker nach jedem Deployment neu gestartet?

#Wie ein Projekt mit mir abläuft

Sie schicken mir schriftlich, welche Aufgaben laufen, wie sie heute gestartet werden und was schon einmal schiefgegangen ist. Ich stelle meine Rückfragen schriftlich und beschreibe danach in einem Dokument Umfang und Annahmen. Änderungen teste ich auf einer Kopie des Systems, nicht auf der Produktion. Den Betrieb der Jobs dokumentiere ich im Repository, damit ein anderer Entwickler ohne mich weiterarbeiten kann.

Was ich von Ihnen brauche: Zugriff auf das Repository und eine Testumgebung, die Liste der geplanten Aufgaben mit ihren Zeiten, die PHP-Version und das Framework. Layout und Gestaltung gehören nicht zu dieser Arbeit, Mockups und Wireframes bringen Sie mit, falls es überhaupt eine Oberfläche gibt.

#Häufige Fragen

#Reicht ein Cronjob, oder brauche ich Scheduler und Queue?

Für eine Aufgabe, die kurz läuft und deren Ausfall niemand bemerkt, reicht Cron. Sobald ein Lauf Geld, Bestände oder Kundenmails berührt, brauchen Sie Sperren gegen doppelte Läufe, Wiederholungen und eine Meldung bei Fehlern. Die bekommen Sie mit dem Scheduler Ihres Frameworks oder einer Queue, nicht mit Cron allein.

#Was bedeutet idempotent bei einem Job?

Ein zweiter Lauf mit denselben Daten ändert nichts mehr am Ergebnis. Ein Import, der Zeilen per Upsert auf einen eindeutigen Schlüssel schreibt, ist idempotent. Ein Job, der einen Zähler hochsetzt oder eine Mail schickt, ist es nicht, solange er sich nicht merkt, was er schon getan hat.

#Was passiert, wenn der Job zweimal gleichzeitig startet?

Ohne Sperre laufen beide. Laravel bietet withoutOverlapping und onOneServer, Symfony eine Sperre am Schedule. Ich verlasse mich zusätzlich auf einen eindeutigen Schlüssel in der Datenbank, denn eine Sperre im Cache kann ablaufen.

#Woran merke ich, dass ein Job nicht mehr läuft?

Ein Job, der nicht startet, schreibt keinen Fehler. Deshalb meldet der Job selbst seinen Erfolg an einen externen Überwachungsdienst oder in eine Tabelle, und eine zweite Stelle schlägt Alarm, wenn die Meldung fehlt.

#Was passiert mit fehlgeschlagenen Jobs?

Sie landen nach den Wiederholungen in der Tabelle failed_jobs (Laravel) oder in der Failure-Transport-Warteschlange (Symfony). Dort schaue ich sie an, behebe die Ursache und stoße sie erneut an. Was dauerhaft nicht klappt, lösche ich bewusst.

#Übernehmen Sie Design und Layout?

Nein. Entwürfe, Mockups und Wireframes kommen vom Auftraggeber. Für Hintergrundjobs braucht es keine, dort geht es um Abläufe und Datenformate.

#Anfrage schicken

Schicken Sie mir über die Kontaktseite die Adresse Ihrer Anwendung, die Liste der Aufgaben, die ausbleiben oder doppelt laufen, den Termin und den aktuellen Stack mit PHP-Version und Framework. Die erste schriftliche Einschätzung ist kostenlos, ich antworte an Werktagen schriftlich.

Brauchen Sie die Leistung: PHP-Entwickler in Münster?

Beschreiben Sie Ihr Projekt per E-Mail. Antwort innerhalb von 48 Stunden.

Projekt beschreiben

Weitere WordPress-Dienste und Wissensbasis entdecken