1. Magazin
  2. /
  3. Programmierung
  4. /
  5. Zugriff auf FTP Storage über Laravel 5.6

Zugriff auf FTP Storage über Laravel 5.6

FTP Server

Zugriff auf FTP Storage über Laravel 5.6

Der Zugriff auf einen FTP Server, sowie auf viele andere Speicherorte ist eine gängige Aufgabe in der Programmierung. Das PHP Framework Laravel bietet für den Zugriff auf viele unterschiedliche Speicherorte eine generische Zugriffsmethode. Amazons S3, der lokale Server-Speicher, FTP Server, etc. werden als disks behandelt. Laravel bietet diese Funktion dank des PHP Pakets Flysystem von  Frank de Jonge an. Detailliertere Informationen zu allen Speichermethoden findet Ihr wie immer in der Dokumentation von Laravel.

In diesem Artikel betrachten wir das Szenario, dass es einen täglichen CSV Export in ein Verzeichnis eines FTP Servers gibt. Die Plattform soll diese Datei dann mit einem nächtlich geplanten Task vom FTP Server laden, überprüfen, ob die Datei bereits importiert wurde und wenn dies nicht der Fall ist, die Datei über die Task Queue importieren. Ich habe auch einen interessanten Artikel über die Laravel Task Queue und einen damit verbundenen CSV Import geschrieben, der für Dich ebenfalls interessant sein könnte.

Ich gehe in diesem Artikel nicht auf die Einrichtung eines kompletten Laravel Projektes ein. Eine gute Anleitung dazu findest Du ebenfalls in der Laravel Dokumentation zur Projekterstellung.

FTP Filesystem Konfiguration

Damit Du FTP als disk benutzen kannst, ist es zunächst wichtig Änderungen an der Konfigurationsdatei “config/filesystems.php” vorzunehmen. Der Array-Knoten “disks”  muss dazu um folgenden Block “ftp” erweitert werden:

'disks' => [

    'local' => [
        'driver' => 'local',
        'root' => storage_path('app'),
    ],

    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => env('APP_URL').'/storage',
        'visibility' => 'public',
    ],

    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'url' => env('AWS_URL'),
    ],

    'ftp' => [
        'driver'   => 'ftp',
        'host'     => 'ftp.example.com',
        'username' => 'your-username',
        'password' => 'your-password',

        // Optional FTP Settings...
        // 'port'     => 21,
        // 'root'     => '',
        // 'passive'  => true,
        // 'ssl'      => true,
        // 'timeout'  => 30,
    ],

],

Du kannst nun die Zugangsdaten zum FTP Server in den Feldern “host”, “username” und “password” hinterlegen. Entweder trägst Du diese direkt ein, oder Du hinterlegst diese in der “.env” Datei Deines Projektes und lädst die Daten wie im Knoten “s3” über die “env()” Funktion. Dies hat den Vorteil, dass Du je nach Umgebung andere Daten hinterlegen kannst, ohne jedesmal Änderungen am Quellcode durchführen zu müssen. Hier findest nochmal ein Beispiel mit env Variablen:

'ftp' => [
    'driver'   => 'ftp',
    'host'     => env('FTP_HOST', 'ftp.example.com'),
    'username' => env('FTP_USERNAME', 'your-username'),
    'password' => env('FTP_PASSWORD', 'your-password'),

    // Optional FTP Settings...
    // 'port'     => env('FTP_PORT', 21),
    // 'root'     => env('FTP_ROOT', ''),
    // 'passive'  => env('FTP_MODE_PASSIVE', true),
    // 'ssl'      => env('FTP_SSL', true),
    // 'timeout'  => env('FTP_TIMEOUT', 30),
],

In der “.env” Datei musst Du diese Variablen jetzt natürlich auch noch hinterlegen. Füge diese am Ende (oder einer von Dir favorisierten Stelle) der Datei ein:

FTP_HOST=ftp.example.com
FTP_USERNAME=your-username
FTP_PASSWORD=your-password
FTP_PORT=21
FTP_ROOT=
FTP_MODE_PASSIVE=true
FTP_SSL=true
FTP_TIMEOUT=30

FTP_IMPORT_DIR="test_verzeichnis/csv_exporte"

Um später den Import-Pfad dynamisch anpassen zu können, hinterlege ich auch diesen als Environment Variable. Nun legen wir noch eine eigene Konfigurationsdatei an, deren Werte wir zum späteren Zeitpunkt mit der “config” Helper-Funktion auslesen können. Infos zu dieser Funktion findest Du im Abschnitt Helper Dokumentation von Laravel. Erstelle nun die Datei “ftpcsvimport.php” im Verzeichnis “config” mit folgendem Inhalt:

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | FTP Import-Verzeichnis
    |--------------------------------------------------------------------------
    |
    | Das Verzeichnis ist das Basisverzeichnis für die zu importierenden
    | Dateien.
    |
    */

    'importDirectory' => env('FTP_IMPORT_DIR', ''),

    /*
    |--------------------------------------------------------------------------
    | Benötigte Spalten-Header
    |--------------------------------------------------------------------------
    |
    | Die folgenden Spalten müssen für den Import bereitstehen.
    |
    */

    'headers' => [

        'dataId',
        'dataName',
        'dataValue',

    ],

    /*
    |--------------------------------------------------------------------------
    | Pflichtspalteninhalte
    |--------------------------------------------------------------------------
    |
    | Die folgenden Spalten der CSV Datei müssen über einen validen Inhalt
    | verfügen.
    |
    */

    'requiredColumns' => [

	    'dataId',
	    'dataName',
	    'dataValue',

    ],

];

Neben der Konfiguration für das Import-Verzeichnis wurden auch die Konfigurationen für den CSV Import bereits hinterlegt. An dieser Stelle musst Du Deine benötigten CSV Spalten-Header und Deine Pflichtspalten, also Spalten die über Inhalte verfügen müssen, hinterlegen. Ich verwende lediglich Beispieldaten zu Anschauungszwecken.

Damit Deine durchgeführte Konfiguration wirksam wird, musst Du zunächst den Laravel Cache neu befüllen. Das kannst Du ganz leicht über den folgenden Konsolenbefehl:

$ php artisan config:cache

Erstellung des CSV-Import Jobs

Die Konfiguration der disks ist abgeschlossen, aber wie bekommen wir nun unsere CSV Datei vom FTP Server? Wir müssen zunächst ein neues Eloquent Model samt Migration anlegen, um bereits importierte Dateien auszuschließen. Dazu führst Du folgenden Befehl im Terminal aus:

$ php artisan make:model ImportedFile -m

Dieser Befehl legt das neue Model “ImportedFile” im Ordner “app/ImportedFile.php” an. Durch das “-m” wird eine passende Datenbankmigration für die neue Tabelle “imported_files” im Ordner “database/migrations” angelegt. Der Name der Datei sollte folgendem Aufbau besitzen “2018_07_16_093346_create_imported_files_table.php”. Selbstverständlich ist bei Dir der Zeitpunkt vor “create_imported_files_table.php” ein anderer als bei mir.

Öffne nun die Migrationsdatei und passe den Inhalt von Schema::create in der Funktion “up” folgendermaßen an:

/**
 * Run the migrations.
 *
 * @return void
 */
public function up()
{
    Schema::create('imported_files', function (Blueprint $table) {
        $table->increments('id');
        $table->timestamps();
        $table->string('file_name')
              ->unique();
    });
}

Durch den “unique” Index muss der Dateiname der Importierten Datei eindeutig und einzigartig sein, und darf nicht nochmals importiert werden. Diese Property muss nun auch noch im Model implementiert werden. Dazu öffnest Du die Datei “app/ImportedFile.php” und fügst folgenden Inhalt ein:

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

/**
 * @property string file_name
 */
class ImportedFile extends Model
{
	/**
	 * The attributes that are mass assignable.
	 *
	 * @var array
	 */
	protected $fillable = [
		'file_name',
	];
}

Zum Schluss musst Du die Migration noch durchführen, damit die Änderungen per “code first” in die Datenbank übertragen werden. Verwende dazu folgenden Befehl in der Konsole (Vergiss aber nicht zuvor die Datenbank Verbindungsvariablen in der .env Datei korrekt zu befüllen):

$ php artisan migrate

Jetzt können wir mit der Erstellung des Jobs fortfahren. Der Job wird später automatisch zu einem geplanten Zeitpunkt (task scheduling) “gequeued” und von einem freien konfigurierten Queue-Worker ausgeführt. Um einen Job anzulegen führst Du folgenden Befehl aus:

$ php artisan make:job ImportCsvFromFtpServer

Im Verzeichnis “app/Jobs” wurde nun die Job Datei “ImportCsvFromFtpServer.php” angelegt. Der CSV Import im Job wird später über das PHP Paket league/csv in der Version 9.0 durchgeführt. Dieses kannst Du über den PHP Abhängigkeitsmanager Composer mit folgendem Befehl installieren:

$ composer require league/csv:^9.0

Es steht Dir natürlich frei, ein anderes PHP Paket, oder zu einem späteren Zeitpunk eine aktuellere Version von league/csv zum Import zu verwenden, oder den Import bspw. über die PHP Funktion str_getcsv selbst zu implementieren. Ich habe mit dem Paket von thephpleague bisher gute Erfahrungen gemacht, da der Implementierungsaufwand um ein vielfaches geringer ist. Das zahlt sich nicht nur für Dich aus, sondern auch für Projektkosten Deines Kunden. Durch den “autoload” Knoten in der “league/csv” composer.json musst Du das Paket nicht in Deiner “app.php” Konfiguration hinterlegen.

Im Import Job ImportCsvFromFtpServer erstellst Du nun zunächst die privaten Properties für das Import Verzeichnis und den CSV Import und befüllst sie im Konstruktor:

/**
 * @var array
 */
private $requiredHeaders;

/**
 * @var array
 */
private $requiredColumns;

/**
 * @var string
 */
private $importDirectory;

/**
 * ImportCsvFromFtpServer constructor.
 */
public function __construct() 
{
	$this->requiredHeaders = config('ftpcsvimport.headers');
	$this->requiredColumns = config('ftpcsvimport.requiredColumns');
	$this->importDirectory = config('ftpcsvimport.importDirectory');
}

Nachdem im Konstruktor das Import-Verzeichnis und die Import-Konfigurationen ermittelt wurden, können wir uns mit dem Import beschäftigen. Aber wie importieren wir nun alle bisher nicht importierten Dateien? Um zu überprüfen, welche Dateien noch nicht importiert wurden, müssen wir erst einmal alle Dateinamen aus dem Import-Verzeichnis ermitteln. Dazu können wir auf unserer FTP disk die Methode “files” verwenden, der wir einen Verzeichnispfad übergeben und zwar den, den wir in der Environment Variable hinterlegt haben.

Danach werden alle Dateinamen der mit einer Schleife durchlaufen und es wird für jeden Dateinamen überprüft, ob er in der Datenbank existiert. Ist das nicht der Fall, so können wir ihn in eine Liste mit nicht importierten Dateien aufnehmen, die zuletzt geladen und importiert werden. In der Praxis sieht das nun folgendermaßen aus:

/**
 * Execute the job.
 *
 * @return void
 */
public function handle()
{
    /** @var array $allFilesInDirectory */
    $allFilesInDirectory = Storage::disk('ftp')
                                  ->files($this->importDirectory);

    if(empty($allFilesInDirectory))
        return;

    /** @var array $filesToImport */
    $filesToImport = [];

    /** @var string $file */
    foreach ($allFilesInDirectory as $file) {
		if(ImportedFile::where('file_name', '=', $file)->exists())
			continue;

		$filesToImport[] = $file;
    }

    if(!empty($filesToImport))
        $this->importFiles($filesToImport);
}

Am Ende der Funktion “handle” wird die Funktion “importFiles” aufgerufen. Diese ist zuständig für das Abarbeiten der Liste aller zu importierenden Dateien. Des weiteren beinhaltet sie die Verwaltung der Datenbank-Transaktion und das Error-Logging:

/**
 * Bearbeitet eine Liste von zu importierenden CSV Dateien.
 *
 * @param array $filesToImport
 */
private function importFiles( $filesToImport )
{
	try {
		DB::beginTransaction();

		/** @var string $file */
		foreach ($filesToImport as $file) {
			if (empty($file) || !Storage::disk('ftp')->exists($file))
				continue;

			if($this->importFile($file))
				ImportedFile::create([
					'file_name' => $file
				]);
		}

		DB::commit();
	} catch (PDOException $e) {
		DB::rollBack();

		Log::error('Während des automatischen CSV-Imports vom FTP-Server kam es zum PDO Fehler innerhalb der 
					Transaktion. Es wurde ein Rollback durchgeführt um den alten Stand der Datenbank wieder 
					herzustellen.');
		Log::error(sprintf('FTP CSV-Import Fehlermeldung: %1$s', $e->getMessage()));
		Log::error(sprintf('FTP CSV-Import Stacktrace: %1$s', $e->getTraceAsString()));
	} catch (Exception $e) {
		DB::rollBack();

		Log::error('Während des automatischen CSV-Imports vom FTP-Server kam es zum allgemeinen Fehler innerhalb der 
					Transaktion. Es wurde ein Rollback durchgeführt um den alten Stand der Datenbank wieder 
					herzustellen.');
		Log::error(sprintf('FTP CSV-Import Fehlermeldung: %1$s', $e->getMessage()));
		Log::error(sprintf('FTP CSV-Import Stacktrace: %1$s', $e->getTraceAsString()));
	}
}

Sollte während des Imports ein Fehler auftreten, so wird ein Rollback der Transaktion durchgeführt und die importierten Daten wieder entfernt. Nach dem Rollback erfolgt das Logging  mit dem konfigurierten Treiber.

Innerhalb der Schleife kann man prüfen, ob die aktuelle Datei im Dateisystem existiert. Ist das der Fall, dann wird der Dateiname an die Funktion “importFile” übergeben, die dann mittels league/csv den CSV Import in die Datenbank durchführt. Wurde die Datei erfolgreich importiert, so gibt die Funktion ein “true” zurück und es wird ein neuer Eintrag in der Tabelle “imported_files” hinzugefügt.

Zu guter Letzt müssen wir nun noch die Funktion “importFile” implementieren. Diese benötigt noch einige Helfer-Funktionen die wir zuvor noch im Job integrieren. Die folgenden Funktionen überprüfen, ob die benötigten Spalten-Header in der CSV-Datei existieren und ob die benötigten Spalten mit Inhalten gefüllt sind. Zuletzt folgt noch eine Funktion zur einfachen Ermittlung eines Array Wertes.

/**
 * Überprüft, ob alle benötigten Spalten vorhanden sind.
 *
 * @param  string[] $csvHeaders
 *
 * @return bool
 */
private function requiredHeadersExist( $csvHeaders )
{
	if(empty($this->requiredHeaders))
		return false;

	/** @var array $reqKeysFlipped */
	$reqKeysFlipped = array_flip($this->requiredHeaders);
	/** @var array $headersFlipped */
	$headersFlipped = array_flip( $csvHeaders);
	/** @var array $intersection */
	$intersection = array_intersect_key($reqKeysFlipped, $headersFlipped);

	return count($intersection) === count($this->requiredHeaders);
}

/**
 * Überprüft, ob alle benötigten Spalten gefüllt sind
 *
 * @param  array $row
 * @return bool
 */
private function requiredColumnsFilled( $row )
{
	/** @var string $requiredColumnKey */
	foreach ($this->requiredColumns as $requiredColumnKey) {
		if(trim($row[$requiredColumnKey]) === "")
			return false;
	}

	return true;
}

/**
 * Holt einen korrekten Wert auf einem ResultSet Array
 *
 * @param mixed $val
 * @param mixed $default
 * @return mixed
 */
private function getValue($val, $default)
{
	return $val === "" ? $default : $val;
}

Die Import Funktion ist sehr komplex und kann auch noch weiter refaktorisiert und aufgespalten werden. Die einzelnen Abschnitte der Funktion kann man im Nachhinein erklären. Wichtig ist, dass Du genau wie in der “ftpcsvimport.php” Dein Model integrierst, das importiert werden soll. Im Beispiel ist das Model “Data” symbolisch für Dein Model hinterlegt. Das Beispiel dient lediglich als Implementierungsgrundlage und muss von Dir angepasst werden.

/**
 * Importiert eine CSV Datei.
 *
 * @param string $file
 * @return bool
 */
private function importFile( $file )
{
	try {
		/** @var string $fileData */
		$fileData = Storage::disk( 'ftp' )
		                   ->get( $file );

		/** @var Reader $reader */
		$reader = Reader::createFromString($fileData);
		$reader->setDelimiter(';');
		$reader->setHeaderOffset(0);

		/** @var ResultSet $resultSetRecords */
		$resultSetRecords = (new Statement())->process($reader);

		if(!$this->requiredHeadersExist($resultSetRecords->getHeader())) {
			Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt nicht über die benötigten Spalten-Header. Die Datei wurde nicht importiert.', $file));
			return false;
		}

		/** @var Generator $csvRows */
		$csvRows = $resultSetRecords->getRecords();

		if(empty($csvRows) || !($csvRows instanceof Generator) || (is_array($csvRows) && sizeof($csvRows) === 0)) {
			Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
			return false;
		}

		/** @var int $countImportedRows */
		$countImportedRows = 0;

		/** @var array $dataRow */
		foreach ($csvRows as $dataRow) {
			if(!$this->requiredColumnsFilled($dataRow))
				continue;

			/** @var int $dataId */
			$dataId = $this->getValue($dataRow['dataId'], 0);

			/** @var Data $data */
			$data = Data::where('id', $dataId)
			            ->first();

			if(empty($data)) {
				Data::create([
					'id'    => $dataId,
					'name'  => $this->getValue($dataRow['dataName'], ''),
					'value' => $this->getValue($dataRow['dataValue'], '')
				]);

				$countImportedRows++;
			}
		}

		if($countImportedRows === 0) {
			Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
			return false;
		}

		return true;
	} catch (FileNotFoundException $e) {
		Log::error(sprintf('Beim Importieren der Datei "%1$s" trat eine FileNotFoundException auf. Die Datei wurde nicht importiert.', $file));
		return false;
	} catch (CsvException $e) {
		Log::error(sprintf('Beim Importieren der Datei "%1$s" trat eine LeagueCsvException auf. Die Datei wurde nicht importiert.', $file));
		return false;
	}
}

Im ersten Abschnitt der Funktion werden die Inhalte der zu importierenden Datei vom FTP Server als Zeichenkette geladen. Über den League Reader werden diese danach mit den CSV Einstellungen, wie bspw. dem Trennzeichen, als ResultSet geladen:

/** @var string $fileData */
$fileData = Storage::disk( 'ftp' )
                   ->get( $file );

/** @var Reader $reader */
$reader = Reader::createFromString($fileData);
$reader->setDelimiter(';');
$reader->setHeaderOffset(0);

/** @var ResultSet $resultSetRecords */
$resultSetRecords = (new Statement())->process($reader);

Das ResultSet überprüft man im Anschluss. Es sollte die benötigten Header, sowie einen Inhalt besitzen:

if(!$this->requiredHeadersExist($resultSetRecords->getHeader())) {
	Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt nicht über die benötigten Spalten-Header. Die Datei wurde nicht importiert.', $file));
	return false;
}

/** @var Generator $csvRows */
$csvRows = $resultSetRecords->getRecords();

if(empty($csvRows) || !($csvRows instanceof Generator) || (is_array($csvRows) && sizeof($csvRows) === 0)) {
	Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
	return false;
}

Nachdem sichergestellt wurde, dass die Datei über die Header, sowie über Inhalt verfügt, beginnt der Import der einzelnen Datensätze der Datei. Mit einer Schleife werden alle Reihen des “Generator” durchlaufen, sichergestellt, dass die benötigten Spalten über Inhalte verfügen und zuletzt das Model in der Datenbank erstellt:

/** @var int $countImportedRows */
$countImportedRows = 0;

/** @var array $dataRow */
foreach ($csvRows as $dataRow) {
	if(!$this->requiredColumnsFilled($dataRow))
		continue;

	/** @var int $dataId */
	$dataId = $this->getValue($dataRow['dataId'], 0);

	/** @var Data $data */
	$data = Data::where('id', $dataId)
	            ->first();

	if(empty($data)) {
		Data::create([
			'id'    => $dataId,
			'name'  => $this->getValue($dataRow['dataName'], ''),
			'value' => $this->getValue($dataRow['dataValue'], '')
		]);

		$countImportedRows++;
	}
}

if($countImportedRows === 0) {
	Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
	return false;
}

Am Ende der Funktion befindet sich noch ein wenig Error-Handling das du beliebig verfeinern und erweitern kannst.

Task Scheduling implementieren

Die Planung von Aufgaben erfolgt in der Datei “Kernel.php”. Diese befindet sich im Verzeichnis “app/Console/”. In der Funktion “schedule” wird unser zuvor erstellter Job nun implementiert:

/**
 * Define the application's command schedule.
 *
 * @param  IlluminateConsoleSchedulingSchedule  $schedule
 * @return void
 */
protected function schedule(Schedule $schedule)
{
    /**
     * Nächtlich laufender Task zum Importieren einer CSV-Datei 
     * vom FTP Server
     * Laufzeit: 0:00 Uhr
     */
    $schedule->job(new ImportCsvFromFtpServer)
             ->dailyAt('0:00');
}

Wird “schedule” nun Aufgerufen, so wird unser Job “geplant” und täglich um “0:00” in die Task-Queue “eingereiht”. Aber woher kommt nun die Queue und wie ruft Laravel automatisch um Mitternacht das “einreihen” in die Task-Queue auf? Welche Queue Du verwendest ist zunächst Dir überlassen. Es gibt viele unterschiedliche Anbieter einer Task-Queue. Detailliertere Informationen zur Einrichtung einer Queue kannst Du der Dokumentation zu Queues entnehmen. Auf der Server-Management Plattform Laravel Forge lassen sich sehr einfach produktive Laravel-Umgebungen auf dem Cloud-Server Anbieter Deiner Wahl einrichten. Deine Queues können im Backend einfach angelegt und konfiguriert werden.

Das selbe gilt für die Ausführung Deiner geplanten Tasks. Dazu ist es nötig, dass Du einen Cron-Job auf Deinem Server einrichtest. Wenn Du Dich nicht mit der Einrichtung von Cron-Jobs auskennst, ist die Verwendung eines von Laravel Forge konfigurierten Webservers für Dich die beste Wahl. Dort kannst Du im Backend einfach konfigurieren, wann “schedule” ausgeführt werden soll. Alternativ kannst Du selbstverständlich selber Cron-Jobs anlegen. Hierzu rufst Du in der Konsole die Cron-Job Verwaltung “crontab” auf:

$ crontab -e

Hier kannst Du nun Deine Cron-Jobs des Servers hinterlegen. Über den folgend Eintrag wird der “scheduler” jede Minute einmal “schedule” aufrufen. Sollte der dort konfigurierte Zeitpunkt erreicht sein, wird bspw. der Import Job gequeued.

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

Fazit zum FTP Server

In diesem Artikel habe ich Dir gezeigt, wie Du in Deiner Laravel-Anwendung einen automatischen CSV-Import von einem FTP Server implementierst. Ich hoffe, dass er Dir weiterhelfen konnte. Solltest Du Fragen haben, oder ich etwas wichtiges Vergessen habe, was Dir weiterhelfen könnte, lass mir doch einen Kommentar da und ich versuche Dir weiterzuhelfen.

Durch unsere langjährige Arbeit und über 100 erfolgreiche Projekte, konnten wir viele Erfahrungen sammeln. Dieses Know-How im Online-Marketing gaben wir u.a. bei Vorträgen von Google, der Industrie- und Handelskammer und der Handwerkskammer weiter.

Mit Know-How, Kreativität und Leidenschaft entwickeln wir auf unsere Kunden abgestimmte Marketing-Strategien, die Sie sicher und nachhaltig zum Erfolg führen. Gemeinsam setzen wir Ihr Online-Marketing so um, dass Sie langfristig Ihren Umsatz und Return-On-Investment steigern.

Jetzt kostenlosen Beratungstermin vereinbaren   oder unter 0561 / 850 194 76 anrufen.