Skip to content

Datenmigration in PHP: Praktische Patterns & Tools

Ein PHP-Elefant, Symbole für Quelltext und Datenbanken und ein PHP-Array mit den Werten 'a', 'b' und 'c'

Ende Juni habe ich online am Laravel Meetup Germany teilgenommen. Es gab zwei Talks, der erste stellte Inertia vor. Inertia ist ein Framework, das es vereinfacht, serverseitige (Laravel-)Apps mit clientseitigen SPAs zu verbinden.

Den zweiten Vortrag hielt Phillip Kalusek über "Data Migrations - Strategies, Tips & Tricks" - nicht zu verwechseln mit Datenbank-Schema-Migrationen - in Laravel (Video auf YouTube. Phillip gibt eine Menge sehr sinnvoller Praxis-Tipps zum Aufsetzen von Import-Workflows und beleuchtet Vor- und Nachteile verschiedener Ansätze. Dieser Vortrag hat mich letztendlich dazu inspiriert, diesen Artikel zu schreiben.

Worum geht's?

Die Ausgangssituation war, dass eine große Anzahl beliebig strukturierter Daten aus externen Quellen in eine Anwendung, also meist in die zugrunde liegende Datenbank importiert werden sollte. Datenquellen gibt es viele, und die Daten liegen dabei in unterschiedlichen Strukturen vor. Sie können aus anderen Datenbanken stammen oder über APIs abgerufen werden. Nicht ungewöhnlich sind auch JSON- oder CSV-Dateien, welche die einzelnen Datensätze zeilenweise enthalten.

Um diese Daten importieren und weiterverarbeiten zu können, müssen sie in der Regel transformiert und normalisiert werden. Zum einen müssen sie häufig an eine existierende Datenstruktur angepasst werden, zum anderen müssen die Datentypen der Werte validiert und gegebenenfalls umgewandelt werden.

Beispiele: * Datumsformat: Ein Datum liegt als String im Format Y-M-D H:i:s vor, es soll aber ein Timestamp (mit Timezone) importiert werden; * Geokoordinaten: Zwei Werte für Längen- und Breitengrad sollen zu einem Point-Datentyp in PostgreSQL vereint werden; * Namen: Die Schreibung von Namen soll angepasst werden; * Die Daten sollen mit bereits vorhandenen Daten angereichert werden; * Es sollen fehlerhafte Datensätze erkannt und entfernt werden; * usw.

Der Schwerpunkt des Talks lag auf einem PHP-basierten Import-Prozess, wie ich ihn auch schon häufig so oder so ähnlich implementiert habe.

Ich möchte aber noch auf einige andere Optionen eingehen, die ich in letzter Zeit verwendet habe und die unter Umständen performanter mit größeren Datenmengen umgehen können oder möglicherweise einfacher und präziser umzusetzen sind.

Beispieldatensatz

Auf der Suche nach Beispieldaten bin ich auf den Datensatz Football - Women's FIFA World Cup & UEFA EURO gestoßen, und weil ja gerade die Fußball-Europameisterschaft stattfindet, habe ich ihn mir genauer angeschaut. Die Daten eignen sich sehr gut zum Experimentieren, denn es sind tatsächlich Geokoordinaten von Fußballstadien, Uhrzeiten in unterschiedlichen Zeitzonen, Spieldaten im JSON-Format und mehr enthalten. Weil ich mich der Einfachheit halber auf Europameisterschaften und nur die wichtigsten Eigenschaften der Spiele konzentrieren möchte, habe ich die Daten etwas bereinigt. So sieht unsere Ausgangsdatenlage aus (Link zur CSV):

id_match,home_team_code,away_team_code,home_score_total,away_score_total,winner_reason,date_time,utc_offset_hours,round,match_attendance,stadium_capacity,stadium_city,stadium_latitude,stadium_longitude,goals
54735,NOR,SWE,2.0,1.0,WIN_REGULAR,1987-06-14 16:00:00+02,2.0,FINAL,8408.0,27184.0,Oslo,59.9490472,10.7342139,"[{""phase"": ""FIRST_HALF"", ""time"": {""minute"": 28, ""second"": 0}, ""international_name"": ""Trude Stendal"", ""club_shirt_name"": ""Stendal"", ""country_code"": ""NOR"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}, {""phase"": ""SECOND_HALF"", ""time"": {""minute"": 72, ""second"": 0}, ""international_name"": ""Trude Stendal"", ""club_shirt_name"": ""Stendal"", ""country_code"": ""NOR"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}, {""phase"": ""SECOND_HALF"", ""time"": {""minute"": 75, ""second"": 0}, ""international_name"": ""Lena Videkull"", ""club_shirt_name"": """", ""country_code"": ""SWE"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}]"
54734,ENG,ITA,1.0,2.0,WIN_REGULAR,1987-06-13 13:30:00+02,2.0,THIRD_PLAY_OFF,500.0,6545.0,Drammen,59.7344111,10.2013194,"[{""phase"": ""FIRST_HALF"", ""time"": {""minute"": 4, ""second"": 0}, ""international_name"": ""Kerry Davis"", ""club_shirt_name"": ""Davis"", ""country_code"": ""ENG"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""PENALTY""}, {""phase"": ""FIRST_HALF"", ""time"": {""minute"": 37, ""second"": 0}, ""international_name"": ""Carolina Morace"", ""club_shirt_name"": ""Morace"", ""country_code"": ""ITA"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}, {""phase"": ""SECOND_HALF"", ""time"": {""minute"": 50, ""second"": 0}, ""international_name"": ""Elisabetta Vignotto"", ""club_shirt_name"": ""Vignotto"", ""country_code"": ""ITA"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}]"
54733,SWE,ENG,3.0,2.0,WIN_ON_EXTRA_TIME,1987-06-11 19:00:00+02,2.0,SEMIFINAL,300.0,2470.0,Moss,59.4207583,10.6710111,"[{""phase"": ""FIRST_HALF"", ""time"": {""minute"": 7, ""second"": 0}, ""international_name"": ""Karin Åhman-Svensson"", ""club_shirt_name"": ""Ahman-Svensson"", ""country_code"": ""SWE"", ""national_field_position"": ""FORWARD"", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}, {""phase"": ""FIRST_HALF"", ""time"": {""minute"": 31, ""second"": 0}, ""international_name"": ""Marieanne Spacey"", ""club_shirt_name"": ""Spacey"", ""country_code"": ""ENG"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}, {""phase"": ""FIRST_HALF"", ""time"": {""minute"": 42, ""second"": 0}, ""international_name"": ""Gillian Coultard"", ""club_shirt_name"": ""Coultard"", ""country_code"": ""ENG"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}, {""phase"": ""SECOND_HALF"", ""time"": {""minute"": 53, ""second"": 0}, ""international_name"": ""Anette Börjesson"", ""club_shirt_name"": ""Börjesson"", ""country_code"": ""SWE"", ""national_field_position"": """", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}, {""phase"": ""EXTRA_TIME_FIRST_HALF"", ""time"": {""minute"": 100, ""second"": 0}, ""international_name"": ""Karin Åhman-Svensson"", ""club_shirt_name"": ""Ahman-Svensson"", ""country_code"": ""SWE"", ""national_field_position"": ""FORWARD"", ""national_jersey_number"": null, ""goal_type"": ""SCORED""}]"
…usw

Um einen ersten Überblick über den Inhalt von CSV-Dateien zu erhalten, bieten sich Kommandozeilen-Tools wie csvlens oder Datenbank-Tools wie SQLite und DuckDB an, die mit entsprechenden Import-Funktionen aufwarten können. Mit deren Hilfe lässt sich oft auf einen Blick einschätzen, welche Daten und Datentypen in der Datei vorhanden sind.

Daten-Import in PHP

"Klassische" foreach-Schleife

Die im Vortrag genannte Methode implementiert einen NormalizationManager, dem ein oder mehrere "Normalizers" übergeben werden. Die "Normalizer" kümmern sich dann nacheinander um die Bereinigung (Normalisierung, Transformierung) der Daten. Die einzelnen Datensätze wurden in einer Schleife nacheinander von den einzelnen "Normalizern" bearbeitet.

In meinem Code-Beispiel habe ich die Klassen TransformerManager und Transformer genannt, sie erfüllen aber dieselbe Aufgabe wie die Normalizer aus dem Vortrag:


class TransformerManager
{
  private array $transformers = [];
 
  public function registerTransformer(string $name, TransformerInterface $transformer): void
  {
    $this->transformers[$name] = $transformer;
  }
 
  public function transformDirectly(array $data): array
  {
    foreach ($this->transformers as $transformer) {
      foreach ($data as &$d) {
        $d = $transformer->transform($d);
      }
    }
    return array_filter($data);
  }
}

class DateTimeTransformer implements TransformerInterface
{
  public function transform(array $data): array
  {
    if (!isset($data['date_time'])) {
      return $data;
    }
    $data['datetime'] = DateTimeImmutable::createFromFormat('Y-m-d H:i:sP', $data['date_time'], new DateTimeZone('Europe/Berlin'));
    return $data;
  }
}
 

Der TransformerManager enthält also die einzelen Transformer wie hier den DateTimeTransformer. Der Methode transformDirectly() wird der Datensatz als array übergeben. Dort wird dann über die Transformer iteriert (in unserem Fall ist es nur einer), und jede Zeile unseres Datensatzes wird separat in der transform()-Methode des Transformers behandelt. Zu guter Letzt sorgt der Aufruf von array_filter() noch dafür, dass leere Datensätze entfernt werden.

Laravel Pipeline Helper

Was da beschrieben wurde, ist im Prinzip ist eine simple Implementierung des Pipeline Design Patterns. Daher bietet es sich im Kontext von Laravel meiner Meinung nach an, den Pipeline Helper (bzw. die Pipeline Facade) von Laravel zu verwenden. Das könnte dann entsprechend so ausssehen:


use Illuminate\Support\Facades\Pipeline;

public function transformLaravelPipeline(array $data): array
{
    foreach ($data as &$d) {
        $d = Pipeline::send($d)
            ->through($this->transformers)
            ->thenReturn();
    }
    return array_filter($data);
}


use Closure;

class DateTimeTransformer implements TransformerInterface
{
  public function __invoke(array $data, Closure $next): array
  {
    $data = $this->transform($data);
    return $next($data);
  }
 
  public function transform(array $data): array
  {
    if (!isset($data['date_time'])) {
      return $data;
    }
    $data['datetime'] = DateTimeImmutable::createFromFormat('Y-m-d H:i:sP', $data['date_time'], new DateTimeZone('Europe/Berlin'));
    return $data;
  }
}
 

Statt des foreach-Loops über die Transformatoren kümmert sich hier die through()-Methode des Pipeline-Helpers darum, dass die Daten durch alle verfügbaren Transformatoren geschickt werden. Die Transformatoren werden durch eine __invoke()-Methode ergänzt, durch die ein zusätzlicher Paramter Closure $next quasi durchgeschleift wird; sie verweist dabei auf den nächsten Transformator.

In einer Laravel-Applikation nutzt dieser Ansatz die mitgebrachten Funktionen des Frameworks eher aus, aber möglicherweise ist die foreach()-Schleife in manchen Szenarien flexibler - das müsst ihr für euch selbst entscheiden ;-)

Pipe Operator in PHP 8.5

Es wird aber noch besser: In PHP 8.5 (erscheint Ende des Jahres 2025) bekommen wir sogar einen nativen Pipe operator: "|>" Wer sich schon einmal mit Programmiersprachen wie Elixir beschäftigt hat, kennt den Pipe Operator wahrscheinlich schon, denn dort ist er ein zentrales Element der Sprache. Mit seiner Hilfe lässt sich eine ähnliche Pipeline realisieren, die voraussichtlich so oder ähnlich aussehen könnte:


public function transformNativePipeline(array $data): array
{
    foreach ($data as &$d) {
        $d = $d
            |> fn($d) => $this->getTransformer(RemoveFutureMatchesTransformer::NAME)->transform($d)
            |> fn($d) => $this->getTransformer(GeoTransformer::NAME)->transform($d)
            |> fn($d) => $this->getTransformer(DateTimeTransformer::NAME)->transform($d)
            |> fn($d) => $this->getTransformer(CleanTransformer::NAME)->transform($d);
    }
    return array_filter($data);
}
 

Jedes einzelne Daten-Array wird also einmal der Reihe nach durch die Transformatoren geschickt und entsprechend angepasst. Ich habe das Code-Beispiel bereits mit der ganz neuen PHP-Version 8.5.0alpha1 getestet und - es funktioniert! 🎆

Alle Code-Beispiele und ein Dockerfile für einen Container mit PHP 8.5 habe ich in einem Repository bereitgestellt.

Weitere Überlegungen

Es ist natürlich auch möglich, das komplette $data-Array an die einzelnen Transformer zu übergeben und erst dort über die enthaltenen Spiele-Arrays zu iterieren. Aber auch das muss im Einzefall entschieden werden. Mir ist es wahrscheinlich lieber, auf mögliche Fehler bei der Bearbeitung eines einzelnen Datensatzes reagieren und die Pipeline dann entsprechend fortsetzen oder abbrechen zu können.

Sollte nämlich während der Verarbeitung ein Fehler auftreten, muss dieser abgefangen werden. Entweder die komplette Verarbeitung stoppt, oder der fehlerbehaftete Datensatz wird übersprungen. Sinnvoll ist im Zusammenhang mit dem Datenbank-Insert auf jeden Fall, die Änderungen innerhalb einer Transaktion zusammenzufassen und, wenn nötig, einen Rollback durchzuführen.

Vor allem wenn die externen Daten nicht aus einer Datenbank, sondern aus einer anderen Quelle stammen, ist es wahrscheinlich sinnvoll, sie in einer (versionierbaren) Datenbank zwischenzuspeichern. Die oben bereits erwähnten Syteme SQLite und DuckDB bieten sich hierfür ebenfalls an. Dabei genügt es unter Umständen, nur die nötigsten Transformationen durchzuführen. Die intermediäre Datenbank wird im Anschluss ihrerseits zur Datenquelle, und die Daten können dann mit allen Transformationen in die finale Datenbank überführt werden. Die dateibasierten Datenbankformate von SQLite und DuckDB eignen sich auch gut für die Versionierung, so dass etwaige Änderungen nachvollzogen werden können.

Stammen die externen Daten aus einer Datenbank, erlauben Frameworks wie Laravel, zwei Datenbank-Verbindungen zu konfigurieren, und die Daten direkt zu transformieren und zu kopieren. Das muss auch gar nicht unbedingt in der "großen Hauptanwendung" geschehen, oft reicht es, ein kleines, aber spezialisiertes Konsolen-Tool mittels Laravel Zero o.ä. zu erstellen, das sich allein um den Daten-Import kümmert.

DTOs

Ein anderer möglicher Ansatz basiert auf Data Transfer Objects (DTOs). Dabei werden Ziel-Objekte mit typisierten Eigenschaften definiert. Die Quell-Daten werden dann mithilfe von Bibliotheken wie Laravel Data oder Valinor auf die so definierten Objekte gemappt. Diese können dann ihrerseits in einer Datenbank gespeichert werden.

Ausblick: Es muss nicht immer PHP sein

Es muss aber gar nicht unbedingt Laravel oder PHP sein - beispielsweise bietet Python hervorragende Tools zur Datentransformation, die ich vielleicht in einem späteren Artikel noch vorstelle. Genannt seien an dieser Stelle besonders pydantic, Pandas und dbt.

Als weitere Möglichkeit, eine Pipeline zur Datentransformation zu realisieren, möchte ich außerdem wirklich gern einen genaueren Blick auf DuckDB werfen. Auch wenn Datenmigration nicht der eigentliche Einsatzzweck von DuckDB ist, lassen sich aufgrund der Flexibilität und Erweiterbarkeit des Tools schnell gute Ergebnisse erzielen.

Für kontinuierliche Daten-Synchronisation, Daten-Replikation oder die Echtzeit-Aktualisierung eines Data Warehouses wiederum gibt es dedizierte Anwendungen, deren Fähigkeiten deutlich über die gezeigten Ansätze hinausgehen, die dafür aber auch aufwändiger aufzusetzen und zu konfigurieren sind.

Fazit

Web-Entwickler*innen mit PHP-/SQL-Kenntnissen können mithilfe der beschriebenen Patterns und Implementierungen Daten schnell und präzise zwischen verschiedenen Systemen migrieren. Mit der nächsten PHP-Version 8.5 wird der Pipeline operator kommen, der solche Abläufe noch lesbarer und einfacher machen kann.

Trackbacks

Keine Trackbacks

Kommentare

Ansicht der Kommentare: Linear | Verschachtelt

Noch keine Kommentare

Kommentar schreiben

Markdown-Formatierung erlaubt
Wenn Du Deinen Twitter Namen eingibst wird Deine Timeline in Deinem Kommentar verlinkt.
Bewirb einen Deiner letzten Artikel
Dieses Blog erlaubt Dir mit Deinem Kommentar einen Deiner letzten Artikel zu bewerben. Bitte gib Deine Blog URL als Homepage ein, dann wird eine Auswahl erscheinen, in der Du einen Artikel auswählen kannst. (Javascript erforderlich)
Standard-Text Smilies wie :-) und ;-) werden zu Bildern konvertiert.
Die angegebene E-Mail-Adresse wird nicht dargestellt, sondern nur für eventuelle Benachrichtigungen verwendet.
Formular-Optionen

Kommentare werden erst nach redaktioneller Prüfung freigeschaltet!