Laravel

Authentication

Laravel 5.7 Multi-Authentication – Unterschiedliche Benutzertypen in 8 Schritten

Laravel ist ein sehr mächtiges PHP Framework das seinen Fokus auf einfachen, simplen Code legt. Es bringt alle Werkzeuge mit, die ein Webentwickler benötigt, um eine zeitgemäße Online-Plattform zu entwickeln. Ein Beispiel ist die Integration von Benutzern. Mit einem Befehl in der Konsole lässt sich Laravels vordefinierte Implementierung der Benutzer in die Plattform integrieren.

Problematischer ist es hingegen wenn man mehrere Benutzertypen benötigt. Unter Benutzertypen verstehe ich die Differenzierung von Benutzern, die nicht das selbe User Model verwenden, also unterschiedliche Eigenschaften innehaben. Ein einfaches Beispiel wäre die Unterscheidung der Benutzertypen in den Plattform-Administrator, der die Plattformeinstellungen verwalten kann und Zugriff auf Logs und Statistiken der Plattform hat, sowie den normalen Benutzer, der Zugriff auf den Service der jeweiligen Plattform hat. Dieser kann bspw. Einkäufe tätigen, ein Abonnement abschließen, oder sich an Deiner Community beteiligen.

Du benötigst keine unterschiedlichen Benutzertypen, wenn sich Deine geplanten Benutzer das selbe User Model teilen und auf der Plattform nur mit unterschiedlichen Rechten agieren. Ein einfaches Beispiel dafür sind die typischen CMS Benutzerrollen wie Mitarbeiter, Autor und Redakteur, bei denen sich die Rechte der Benutzer zunehmend staffeln. Strebst Du ein solches Modell an, solltest Du Dirüberlegen, die Standardimplementierung der Benutzer um ein Rollen- und Rechtemodell zu erweitern.

In diesem Blogartikel werde ich Dir zeigen, wie Du in acht Schritten Deine eigenen Benutzertypen in Deine Laravel Anwendung integrieren kannst. Du wirst zunächst die Standardimplementierung von Laravel erweitern, danach wirst Du die benötigten Views und Routen bereitstellen. Ein weiterer wichtiger Punkt ist die Erweiterung der Authentication Middleware und die Einrichtung der Redirects. Zum Schluss stellst Du Deinerm neuen Benutzertypen noch die Möglichkeit zur Verfügung sich abzumelden und sein Passwort zurückzusetzen.

Also Grundlage setze ich voraus, dass Du kein Einsteiger in Laravel bist. Du solltest die Grundbausteine von Laravel kennen und wissen wie Du Sie anwendest. Artisan, Controller, Views, Middlewares und Traits sollten für Dich keine Fremdwörter sein. Wir steigen im nächsten Abschnitt direkt mit der Implementierung ein, ein Projekt sollte also bereits bestehen.

Die Integration der normalen Laravel Authentication

Zur Vorbereitung der unterschiedlichen Benutzertypen integrierst Du zunächst die Standard Authentication von Laravel. Auf dieser baust Du Schritt für Schritt Deinen neuen Benutzertypen auf. Du verwendest Sie als Vorlage und sparst Dir damit viel Zeit. Du passt die integrierte Standard Authentication aber nicht an, sondern Du adaptierst sie. Um später zwei unterschiedliche Typen zu verwenden musst Du Dich lediglich an der Standard Implementierung entlanghangeln und baust Dir neues User Model.

Richte nun über Artisan die Authentication ein:

$ php artisan make:auth

Artisan generiert für Dich nun einige Dinge, wie unter anderem Views zur Anmeldung, Registrierung und für Dein Dashboard, sowie Routen zu den neuen Seiten und einen Controller (HomeController.php) für Dein Dashboard. Im app Order findest Du das User Model, an dieser Stelle erstellst Du gleich das neue User Model. All das machst Du im Zuge einer Datenbank Migration.

Unter Deinen Migrationen /database/migrations/ befinden sich bereits zwei vorgefertigte Migrationen von Laravel. Diese wurden nicht von Artisan hinzugefügt. Die Migrationen erzeugen Deine „users“ und Deine „forgot my password“ Tabelle. In der Tabelle „users“ werden unsere normalen Benutzer gespeichert, diese adaptierst Du im nächsten Schritt. Die Tabelle für „forgot my password“ musst Du nicht für Deinen neuen Benutzertyp dublizieren, denn alle Benutzertypen können diese gemeinsam benutzen.

In den nachfolgenden Beispielen verwende ich den Benutzertyp für den B2B Bereich „BusinessUser“ als representativen neuen Benutzertypen.

Erstelle nun mit Artisan im Terminal eine neue Migration um Deine Benutzer des neuen Benutzertypes speichern zu können:

$ php artisan make:migration create_business_users_table --create=business_users

Die gerade erstellte Migration wird mit einer Datierung versehen und unter /database/migrations/ gespeichert. Durch die flag „--create=business_users“ wird die Migration bereits so angepasst, dass die Tabelle mit dem Namen „business_users“ angelegt wird.

Eine alternative Herangehensweise wäre es, das Model über Artisan anzulegen und dabei die passende Migration zu erstellen:

$ php artisan make:model BusinessUser -m

Jetzt musst Du die Migrationsdatei um die Eigenschaften Deines neuen Benutzertypes erweitern. Suche die Migrationsdatei im Verzeichnis /database/migrations/ und öffne sie. Passe die benötigten Eigenschaften so an, dass die Tabelle über alle benötigten Spalten verfügt. Nachfolgend findest Du die angepasste Migration des BusinessUser Models:

<?php

use IlluminateSupportFacadesSchema; 
use IlluminateDatabaseSchemaBlueprint; 
use IlluminateDatabaseMigrationsMigration; 

class CreateBusinessUsersTable extends Migration 
{ 
    /** 
     * Run the migrations. 
     * 
     * @return void 
     */ 
    public function up() 
    { 
        Schema::create('business_users', function (Blueprint $table) { 
            $table->increments('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('company');
            $table->string('vat');
            $table->string('street_name');
            $table->string('street_number');
            $table->string('city');
            $table->string('zip');
            $table->string('password');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('business_users');
    }
}

Solltest Du Fragen zum Erstellen von Migrationen haben, bietet Dir die Laravel Dokumentation ausreichend Informationen und Beispiele an: Laravel Dokumentation - Migrationen

In dieser Migration haben wir einige Spalten für die Eigenschaften unseres neuen Benutzertypes hinzugefügt, die dafür sorgen, das sich dieser von unserem User Benutzertyp unterscheidet. Beachte, dass die Spalte „email_verified_at“ Bestandteil des aus Laravel 5.7 stammenden, neuen Features zur E-Mail Verifizierung ist.

Durch das Hinzufügen von $table->rememberToken(); wird für das Model die Funktionalität „Angemeldet bleiben“ aktiviert und wir können dieses in der Login Maske für unseren neuen Benutzertypen nutzen. Im nächsten Schritt können wir die Migration bereits durchführen.

Hinweis: Da ich für diesen Artikel Vorwissen in der Entwicklung von Laravel Applikationen voraussetze gehe ich nicht auf die .env Konfiguration ein. Denke bitte daran, Deine Environment Datei zu konfigurieren und eine Datenbank einzurichten bevor Du die Migrationen ausführst.

$ php artisan migrate

Die Tabellen für die Authentication der normalen Benutzer und der B2B User wurden nun integriert. Im nächsten Schritt schauen wir uns die zugehörigen User Models an.

Aufsetzen der User Models

Das User Model für den normalen Benutzer existiert bereits im Ordner app. Das User Model funktioniert auch bereits und kann direkt verwendet werden. Du kannst das existierende User Model User.php nun einfach dublizieren und umbenennen. In diesem Beispiel heißt das neue Model BusinessUser.php. Lass uns nun einen Blick in das neue Model werfen:

<?php

namespace App;

use IlluminateNotificationsNotifiable;
use IlluminateContractsAuthMustVerifyEmail;
use IlluminateFoundationAuthUser as Authenticatable;

class BusinessUser extends Authenticatable implements MustVerifyEmail
{
	use Notifiable;

	protected $guard = 'business_user';

	/**
	 * The attributes that are mass assignable.
	 *
	 * @var array
	 */
	protected $fillable = [
		'name',
		'email',
		'password',
		'company',
		'vat',
		'street_name',
		'street_number',
		'city',
		'zip'
	];

	/**
	 * The attributes that should be hidden for arrays.
	 *
	 * @var array
	 */
	protected $hidden = [
		'password', 'remember_token',
	];
}

Durch die Duplizierung des User Model hast Du alle relevanten Bestandteile für Dein eigenes User Model bereits integriert. Du solltest aber noch ein paar Anpassungen vornehmen.

Zeile 9: Passe den Klassennamen an Dein User Model an. Im Beispiel wurde User in „BusinessUser“ geändert.

Zeile 9: Implementiere in der Zeile 8 noch das Interface „MustVerifyEmail“. Dadurch muss der Benutzer nach der Registrierung seine E-Mail Adresse bestätigen. Der Benutzer erhält automatisch die Notification „NotificationsVerifyEmail“, in der sich ein Link zur E-Mail Bestätigung befindet. Das Interface kannst Du übrigens auch im User Model User.php hinzufügen, damit auch normale Benutzer ihre E-Mail Adresse bestätigen müssen.

Zeile 13: Um später den Zugriff auf Controller nur für Benutzer des neuen Benutzertypes zu beschränken hinterlegst Du in der protected Eigenschaft „guard“ den Namen Deines User Models. Im nächsten Schritt stellen wir die passende guard in der auth.php Konfiguration bereit.

Zeile 20: Integriere in das fillable Array all Deine Benutzereigenschaften, die per „create“ befüllt werden können.

Aufsetzen des Guards

In Laravel ist es möglich mehrere konfigurierte Benutzertypen zu identifizieren. Die Zugriffsbeschränkung wird mit sogenannten „Guards“ vorgenommen. In Laravel können so viele Guards konfiguriert werden, wie Du sie benötigst, es gibt keine Einschränkung.

Die Standard Guards und neue Guards werden in der Authentication Konfiguration unter config/auth.php verwaltet und konfiguriert. Im folgenden Beispiel siehst Du die Standard auth.php ohne die Kommentare:

<?php

return [
    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => AppUser::class,
        ],
    ],
…

Im ersten Teil des Arrays siehst Du die Standardeinstellungen. Als Standardwert für guard ist „web“ festgelegt. Sollte bei einer Middleware keine Guard festgelegt sein, wird automatisch die „web“ Guard verwendet.

Im nächsten Array Knoten „guards“ sind die vorhandenen Guards „web“ und „api“ hinterlegt. Beide teilen sich den Provider „users“. Unterscheiden tuen sie sich lediglich über den hinterlegten Driver. Bei einem Web Zugriff soll ein Session/ Cookie basierter Treiber verwendet werden und bei einem API-Request ein Token basierter.

Direkt unterhalb der Guards findest Du die Provider. Als einziger Provider sollte dort momentan „users“ hinterlegt sein. „users“ verwendet als Driver wiederum Eloquent und das zugeordnete User Model ist AppUser. An dieser Stelle kann auch database als Driver hinterlegt werden und anstelle des User Models trägst Du in diesem Fall den Tabellennamen ein.

Im nächsten Schritt legst Du Deinen eigenen Provider und Deine eigenen Guards an. Für dieses Tutorial beschränke ich mich im Detail darauf, einen Guard zu integrieren, der Session basiert arbeitet. Füge nun die Knoten an die jeweiligen Arrays an, Deine auth config sollte so ähnlich aussehen, wie in folgendem Beispiel:

<?php

return [
    'defaults' => [
        'guard' => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'api' => [
            'driver' => 'token',
            'provider' => 'users',
        ],
        'business_user' => [
            'driver' => 'session',
            'provider' => 'business_users',
        ],
        'business_user_api' => [
            'driver' => 'token',
            'provider' => 'business_users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => AppUser::class,
        ],
        'business_users' => [
            'driver' => 'eloquent',
            'model' => AppBusinessUser::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
        ],

        'business_users' => [
	        'provider' => 'business_users',
	        'table' => 'password_resets',
	        'expire' => 10,
        ],
    ],

];

Stelle sicher, dass beim Model das richtige Model hinterlegt ist und dass Du die ::class Syntax verwendest.

Nun steht Dir der relevante Guard für Dein User Model zur Verfügung. Dieser steht jetzt in Verbindung mit Deinem User Model und in dem sollte jetzt auch die protected Eigenschaft mir dem Name der Guard übereinstimmen.

Testen der Zugriffsbeschränkung

Zum Testen der Zugriffsbeschränkung dublizierst Du in diesem Schritt die „home“ View. Dadurch baust Du ein Dashboard/ eine Startseite für Deinen neuen Benutzertyp. Auf diese Seite haben lediglich angemeldete Business User Zugriff. Gäste werden auf den Business User Login weitergeleitet und normale Benutzer ebenfalls. Da normale Benutzer aber bereits angemeldet sind, werden diese vom Login auf ihre eigene Startseite weitergeleitet.

Erstelle nun einen „Dashboard“ Controller für Deinen neuen Benutzertyp:

$ php artisan make:controller BusinessUserHomeController

In dem erzeugten Controller kannst Du die Funktion „index“ vom HomeController adaptieren:

<?php

namespace AppHttpControllers;

use IlluminateHttpRequest;
use IlluminateHttpResponse;

class BusinessUserHomeController extends Controller
{
	/**
	 * Create a new controller instance.
	 *
	 * @return void
	 */
	public function __construct()
	{
		$this->middleware('auth:business_user');
	}

	/**
	 * Show the application dashboard.
	 *
	 * @return Response
	 */
	public function index()
	{
		return view('businessuser.home');
	}
}

Wie Du im Konstruktor erkennen kannst, erweitere ich im Beispiel die Middleware „auth“ um einen Parameter, was an dem Doppelpunkt zu erkennen ist. Nach dem Doppelpunkt folgt die Guard, die wir in den vorherigen Schritten definiert haben :business_user. Mehr zur Verwendung von Parametern in der Middleware findest Du unter: Laravel Dokumentation - Middleware. In der Funktion „index“ hinterlegst Du noch Deine View, in meinem Fall ist das „businessuser.home“.

Nun kannst Du die „home“ View kopieren und änderst zu Testzwecken die Dashboad-Bezeichnungen, damit ersichtlich ist, welche View geladen wurde:

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">Dashboard für Geschäftskunden</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

                    Sie befinden sich im Dashboard für Geschäftskunden.
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Zuletzt hinterlegst Du noch die Route für Deine neue Dashboard View in der Datei routes/web.php:

<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', 'HomeController@index')->name('home');
Route::get('/business-user/home', 'BusinessUserHomeController@index')->name('business_user.home');

Greifst Du nun als angemeldeter normaler Benutzer auf die Seite „/business-user/home“ zu, wirst Du auf Dein Dashboard „/home“ redirected.

Registrierung unterschiedlicher Benutzertypen

Kommen wir nun zur Registrierung. Über die Standardregistrierung, die mit „make:auth“ angelegt wurde, können keine eigenen Benutzertypen registriert werden. Ich würde kein Auswahlfeld für den Benutzertypen hinzufügen, da normale Benutzer ansonsten verwirrt seien könnten. Da der neue Benutzertyp sowieso andere Eigenschaften haben sollte als der Standardbenutzer, teilen sich diese nicht die Felder bei der Registrierung. Wenn es sich bei Deinem Benutzertypen um einen Administrator handelt, benötigst Du keine weitere Registrierung. Diesen kannst Du dann über einen Seed anlegen. Wie das funktioniert zeige ich Dir im nächsten Kapitel. Nun erst mal zur Registrierung. Erzeuge einen neuen Registrierungs Controller im Unterordner „Auth“:

$ php artisan make:controller Auth/RegisterBusinessUserController

Nun übernimmst Du schrittweise alle wichtigen Komponenten des RegisterController:

<?php

namespace AppHttpControllersAuth;

use AppBusinessUser;
use AppHttpControllersController;
use IlluminateFoundationAuthUser;
use IlluminateHttpResponse;
use IlluminateSupportFacadesHash;
use IlluminateSupportFacadesValidator;
use IlluminateFoundationAuthRegistersUsers;

class RegisterBusinessUserController extends Controller
{
	use RegistersUsers;

	/**
	 * Where to redirect users after registration.
	 *
	 * @var string
	 */
	protected $redirectTo = '/business-user/home';

	/**
	 * Create a new controller instance.
	 *
	 * @return void
	 */
	public function __construct()
	{
		$this->middleware('guest');
	}

	/**
	 * Show the application registration form.
	 *
	 * @return Response
	 */
	protected function showRegistrationForm()
	{
		return view('businessuser.auth.register');
	}

	/**
	 * Get a validator for an incoming registration request.
	 *
	 * @param  array  $data
	 * @return IlluminateContractsValidationValidator
	 */
	protected function validator(array $data)
	{
		return Validator::make($data, [
			'name' => 'required|string|max:255',
			'email' => 'required|string|email|max:255|unique:users',
			'password' => 'required|string|min:6|confirmed',
			'company' => 'required|string|max:255',
			'vat' => 'required|string|max:255',
			'street_name' => 'required|string|max:255',
			'street_number' => 'required|string|max:255',
			'city' => 'required|string|max:255',
			'zip' => 'required|string|max:255',
		]);
	}

	/**
	 * Create a new user instance after a valid registration.
	 *
	 * @param  array  $data
	 * @return User
	 */
	protected function create(array $data)
	{
		return BusinessUser::create([
			'name' => $data['name'],
			'email' => $data['email'],
			'password' => Hash::make($data['password']),
			'company' => $data['company'],
			'vat' => $data['vat'],
			'street_name' => $data['street_name'],
			'street_number' => $data['street_number'],
			'city' => $data['city'],
			'zip' => $data['zip']
		]);
	}
}

Zunächst integrierst Du den Trait RegistersUsers. Über die protected Eigenschaft redirectTo kannst Du das Redirect Ziel nach einer erfolgreichen Registrierung festlegen. Im Konstruktor legst Du über die Middleware „Guest“ fest, dass angemeldete Benutzer die Seite nicht besuchen können. Diese werden auf ihr Dashboard weitergeleitet.

Zum Schluss überschreibst Du noch die Funktionen „validator“ und „create“. Du erweiterst die Validierung um die Felder Deines Registrierungsformulars und in der Erstellungsfunktion initialisierst Du über die statische create Funktion Dein User Model und gibst es zurück. Dadurch, dass Dein User Model von der Basisklasse Authenticatable abgeleitet ist, werden alle weiteren Schritte durch die Funktionen im Trait erledigt.

Um den Prozess der Registrierung Deines eigenen Benutzertypen zu vervollständigen fehlt nun nur noch eine View. Im Controller überschreibst Du die Trait Funktion „showRegistrationForm“ und gibst dort die View Deiner Wahl zurück. Du kopierst nun die register View der normalen Registrierung und fügst die von Dir benötigten Felder hinzu. Hier findest Du die modifizierte View, mit den Eigenschaften aus meinem neuen Benutzertypen:

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">Geschäftskundenregistrierung</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('business_user.register.post') }}">
                        @csrf

                        <div class="form-group row">
                            <label for="name" class="col-md-4 col-form-label text-md-right">Geschäftsführung</label>

                            <div class="col-md-6">
                                <input id="name" type="text" class="form-control{{ $errors->has('name') ? ' is-invalid' : '' }}" name="name" value="{{ old('name') }}" required autofocus>

                                @if ($errors->has('name'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('name') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="company" class="col-md-4 col-form-label text-md-right">Firma</label>

                            <div class="col-md-6">
                                <input id="company" type="text" class="form-control{{ $errors->has('company') ? ' is-invalid' : '' }}" name="company" value="{{ old('company') }}" required>

                                @if ($errors->has('company'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('company') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="vat" class="col-md-4 col-form-label text-md-right">USt-ID</label>

                            <div class="col-md-6">
                                <input id="vat" type="text" class="form-control{{ $errors->has('vat') ? ' is-invalid' : '' }}" name="vat" value="{{ old('vat') }}" required>

                                @if ($errors->has('vat'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('vat') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="street_name" class="col-md-4 col-form-label text-md-right">Straße</label>

                            <div class="col-md-6">
                                <input id="street_name" type="text" class="form-control{{ $errors->has('street_name') ? ' is-invalid' : '' }}" name="street_name" value="{{ old('street_name') }}" required>

                                @if ($errors->has('street_name'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('street_name') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="street_number" class="col-md-4 col-form-label text-md-right">Hausnummer</label>

                            <div class="col-md-6">
                                <input id="street_number" type="text" class="form-control{{ $errors->has('street_number') ? ' is-invalid' : '' }}" name="street_number" value="{{ old('street_number') }}" required>

                                @if ($errors->has('street_number'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('street_number') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="city" class="col-md-4 col-form-label text-md-right">Ort</label>

                            <div class="col-md-6">
                                <input id="city" type="text" class="form-control{{ $errors->has('city') ? ' is-invalid' : '' }}" name="city" value="{{ old('city') }}" required>

                                @if ($errors->has('city'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('city') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="zip" class="col-md-4 col-form-label text-md-right">PLZ</label>

                            <div class="col-md-6">
                                <input id="zip" type="text" class="form-control{{ $errors->has('zip') ? ' is-invalid' : '' }}" name="zip" value="{{ old('zip') }}" required>

                                @if ($errors->has('zip'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('zip') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ old('email') }}" required>

                                @if ($errors->has('email'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password" class="col-md-4 col-form-label text-md-right">Passwort</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" name="password" required>

                                @if ($errors->has('password'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('password') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password-confirm" class="col-md-4 col-form-label text-md-right">Passwort wiederholen</label>

                            <div class="col-md-6">
                                <input id="password-confirm" type="password" class="form-control" name="password_confirmation" required>
                            </div>
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">Jetzt registrieren</button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Als nächstes platzierst Du in Deiner „welcome“ View einen Link auf die neue Registrierung:

<!doctype html>
<html lang="{{ app()->getLocale() }}">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet" type="text/css">

        <!-- Styles -->
        <style>
            html, body {
                background-color: #fff;
                color: #636b6f;
                font-family: 'Nunito', sans-serif;
                font-weight: 200;
                height: 100vh;
                margin: 0;
            }

            .full-height {
                height: 100vh;
            }

            .flex-center {
                align-items: center;
                display: flex;
                justify-content: center;
            }

            .position-ref {
                position: relative;
            }

            .top-right {
                position: absolute;
                right: 10px;
                top: 18px;
            }

            .content {
                text-align: center;
            }

            .title {
                font-size: 84px;
            }

            .links > a {
                color: #636b6f;
                padding: 0 25px;
                font-size: 12px;
                font-weight: 600;
                letter-spacing: .1rem;
                text-decoration: none;
                text-transform: uppercase;
            }

            .m-b-md {
                margin-bottom: 30px;
            }
        </style>
    </head>
    <body>
        <div class="flex-center position-ref full-height">
            @if (Route::has('login'))
                <div class="top-right links">
                    @auth
                        <a href="{{ url('/home') }}">Home</a>
                    @else
                        <a href="{{ route('login') }}">Login</a>
                        <a href="{{ route('register') }}">Registrierung</a>
                        <a href="{{ route('business_user.register.get') }}">Geschäftskundenregistrierung</a>
                    @endauth
                </div>
            @endif

            <div class="content">
                <div class="title m-b-md">
                    Laravel
                </div>

                <div class="links">
                    <a href="https://laravel.com/docs">Documentation</a>
                    <a href="https://laracasts.com">Laracasts</a>
                    <a href="https://laravel-news.com">News</a>
                    <a href="https://nova.laravel.com">Nova</a>
                    <a href="https://forge.laravel.com">Forge</a>
                    <a href="https://github.com/laravel/laravel">GitHub</a>
                </div>
            </div>
        </div>
    </body>
</html>

Damit die integrierten Routen funktionieren, müssen diese noch in Deinen Web-Routen hinterlegt werden:

<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home/', 'HomeController@index')
     ->name('home');

Route::prefix('business-user')->group(function () {
	Route::get('/home/', 'BusinessUserHomeController@index')
	     ->name('business_user.home');

	Route::get('/register/', 'AuthRegisterBusinessUserController@showRegistrationForm')
	     ->name('business_user.register.get');
	Route::post('/register/', 'AuthRegisterBusinessUserController@register')
	     ->name('business_user.register.post');
});

Zur besseren Gliederung der Routen wurden diese im einem Routen Prefix gruppiert. Du kannst Dir nun mit Hilfe von Artisan Deine Routing-Tabelle anzeigen lassen:

$ php artisan route:list

Oder die Kurzform:

$ php artisan r:l

Eigene E-Mail-Verifizierung – Seit Laravel 5.7

Diesen Schritt kannst Du nur integrieren, wenn Du mit einem Laravel 5.7 Projekt arbeitest. Vorher gab es die eigens von Laravel bereitgestellte E-Mail-Verifizierung noch nicht.

Im nächsten Schritt integrierst Du das E-Mail-Verifizierungsverfahren. Laravel hat in der Version 5.7 ein eigenes E-Mail Verifizierungsverfahren erhalten. Dieses ist ebenfalls abgestimmt auf den Standard Laravel Benutzer und Du integrierst nun Dein eigenes Verifizierungsverfahren, basiert auf dem von Laravel. Da Du zuletzt Routen in der web.php ergänzst hast setzen wir hier auch wieder an.
In der Zeile 18 muss beim Aufruf von Auth::routes(); noch ein Array mit Argumenten übergeben werden. Als Parameter setzt Du wie im folgenden Beispiel verify = true.

Im Prefixknoten „business-user“ integrierst Du nun drei neue Verifizierungsrouten:

<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Auth::routes(['verify' => true]);

Route::get('/home/', 'HomeController@index')
     ->name('home');

Route::prefix('business-user')->group(function () {
	Route::get('/home/', 'BusinessUserHomeController@index')
	     ->name('business_user.home');

	Route::get('/register/', 'AuthRegisterBusinessUserController@showRegistrationForm')
	     ->name('business_user.register.get');
	Route::post('/register/', 'AuthRegisterBusinessUserController@register')
	     ->name('business_user.register.post');

	Route::get('/email/resend/', 'BusinessUserVerificationController@resend')
	     ->name('business_user.verification.resend');
	Route::get('/email/verify/', 'BusinessUserVerificationController@show')
	     ->name('business_user.verification.notice');
	Route::get('/email/verify/{id}/', 'BusinessUserVerificationController@verify')
	     ->name('business_user.verification.verify');
});

Lege nun den Verifizierungs-Controller über Artisan an:

$ php artisan make:controller BusinessUserVerificationController

In dem Controller impelementierst Du alle Inhalte des AuthVerificationController, dieser dient nun als Grundlage.

<?php

namespace AppHttpControllers;

use IlluminateHttpRequest;
use IlluminateHttpResponse;
use IlluminateFoundationAuthVerifiesEmails;

class BusinessUserVerificationController extends Controller
{
	use VerifiesEmails;

	/**
	 * Where to redirect users after verification.
	 *
	 * @var string
	 */
	protected $redirectTo = '/business-user/home';

	/**
	 * Create a new controller instance.
	 *
	 * @return void
	 */
	public function __construct()
	{
		$this->middleware('auth:business_user');
		$this->middleware('signed')->only('verify');
		$this->middleware('throttle:6,1')->only('verify', 'resend');
	}

	/**
	 * Show the email verification notice.
	 *
	 * @param  Request  $request
	 * @return Response
	 */
	public function show(Request $request)
	{
		return $request->user()->hasVerifiedEmail()
			? redirect($this->redirectPath())
			: view('businessuser.verify');
	}
}

Am Controller musst Du nun noch einige Anpassungen durchführen. Wie im Beispiel zu sehen ist, wurde die protected Eigenschaft redirectTo an die Dashboard URL des neuen Benutzertypen angepasst und die „auth“ Middleware wurde um den Parameter „business_user“ erweitert. Weiterhin wurde die Funktion „show“ aus dem VerifiesEmails Trait überschrieben, damit der Benutzer korrekt weitergeleitet wird.

Als nächstes musst Du das E-Mail-Template/ die Laravel Notification für die Verifizierung anpassen. Der Link in der E-Mail zeigt auf die falsche Verifizierungsseite. Zunächst implementierst Du in Deinem neuen User Model, bei mir ist das AppBusinessUser.php, die Funktion „sendEmailVerificationNotification“, die vom Interface „MustVerifyEmail“ vorgegeben wird. Du musst nicht alle Funktionen des Interfaces integrieren. Diese kommen aus dem Trait IlluminateAuthMustVerifyEmail, der im Authenticatable integriert wird.

In der Funktion integrierst Du nun Deine eigene Notification, die Du im Anschluss erstellst:

<?php

namespace App;

use IlluminateNotificationsNotifiable;
use IlluminateContractsAuthMustVerifyEmail;
use IlluminateFoundationAuthUser as Authenticatable;

class BusinessUser extends Authenticatable implements MustVerifyEmail
{
	use Notifiable;

	/**
	 * The attributes that are mass assignable.
	 *
	 * @var array
	 */
	protected $fillable = [
		'name',
		'email',
		'password',
		'company',
		'vat',
		'street_name',
		'street_number',
		'city',
		'zip'
	];

	/**
	 * The attributes that should be hidden for arrays.
	 *
	 * @var array
	 */
	protected $hidden = [
		'password', 'remember_token',
	];

	/**
	 * Versendet eine E-Mail-Verifizierungs Benachrichtigung an den Benutzer
	 */
	public function sendEmailVerificationNotification()
	{
		$this->notify(new NotificationsBusinessUserVerifyEmail);
	}
}

Erstelle jetzt mit Artisan eine Notification:

$ php artisan make:notification BusinessUserVerifyEmail

Laravel hat nun im Verzeichnis app/Notifications die neue Notification BusinessUserVerifyEmail angelegt. Nun müssen wir den Inhalt der alten Notification ermitteln und übernehmen. Das einzige was wir anpassen müssen, ist die temporäre URL zur Aktivierung. Die ursprüngliche Notification ist im Trait MustVerifyEmail hinterlegt und ist im Namespace IlluminateAuthNotifications enthalten:

<?php

namespace AppNotifications;

use IlluminateBusQueueable;
use IlluminateNotificationsNotification;
use IlluminateContractsQueueShouldQueue;
use IlluminateNotificationsMessagesMailMessage;
use IlluminateSupportCarbon;
use IlluminateSupportFacadesLang;
use IlluminateSupportFacadesURL;

class BusinessUserVerifyEmail extends Notification implements ShouldQueue
{
	use Queueable;

	/**
	 * The callback that should be used to build the mail message.
	 *
	 * @var Closure|null
	 */
	public static $toMailCallback;

	/**
	 * Get the notification's delivery channels.
	 *
	 * @param  mixed  $notifiable
	 * @return array
	 */
	public function via($notifiable)
	{
		return ['mail'];
	}

	/**
	 * Get the mail representation of the notification.
	 *
	 * @param  mixed  $notifiable
	 * @return IlluminateNotificationsMessagesMailMessage
	 */
	public function toMail($notifiable)
	{
		if (static::$toMailCallback) {
			return call_user_func(static::$toMailCallback, $notifiable);
		}
	
		return (new MailMessage)
			->subject(Lang::getFromJson('Verify Email Address'))
			->line(Lang::getFromJson('Please click the button below to verify your email address.'))
			->action(
				Lang::getFromJson('Verify Email Address'),
				$this->verificationUrl($notifiable)
			)
			->line(Lang::getFromJson('If you did not create an account, no further action is required.'));
	}

	/**
	 * Get the verification URL for the given notifiable.
	 *
	 * @param  mixed  $notifiable
	 * @return string
	 */
	protected function verificationUrl($notifiable)
	{
		return URL::temporarySignedRoute(
			'business_user.verification.verify', Carbon::now()->addMinutes(60), ['id' => $notifiable->getKey()]
		);
	}

	/**
	 * Set a callback that should be used when building the notification mail message.
	 *
	 * @param  Closure  $callback
	 * @return void
	 */
	public static function toMailUsing($callback)
	{
		static::$toMailCallback = $callback;
	}
}

In diesem Beispiel habe ich noch das Interface ShouldQueue implementiert und in den Zeilen 65-67 die temporär signierte Route auf die Verifizierungsseite des BusinessUser‘s geändert.

Anlegen von Benutzern über Database Seeding

Dieser Schritt ist zur Integration nicht notwendig. Es ist Dir selbst überlassen, ob Du das Database Seeding für Deinen eigenen Benutzertypen benötigst.

Wenn Dein neuer Benutzertyp bspw. ein Administrator ist und Du nicht möchtest, dass sich dieser registrieren kann, so kannst Du das Database Seeding von Laravel nutzen. Detaillierte Informationen zum Seeding findest Du unter: Laravel Dokumentation - Seeding.

Seeding ermöglicht Dir, über einen Befehl Deine Datenbank mit Daten zu befüllen. So kannst Du einen Prozess entwickeln, um einen default Administrator anzulegen, sollte noch keiner im System hinterlegt sein. Um Dir einen Administratoren Seed anzulegen, führe folgenden Artisan Befehl im Terminal aus:

$ php artisan make:seeder AdminTableSeeder

Unter database/seeds wurde nun Dein Seeder AdminTableSeeder angelegt. Um einen default Administrator zu hinterlegen, kannst Du folgendes Beispiel-Script als Grundlage verwenden:

<?php

use AppAdministrator;
use IlluminateDatabaseSeeder;
use IlluminateSupportFacadesHash;
use IlluminateDatabaseEloquentCollection;

class AdminTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        /** @var Collection $administrators */
        $administrators  = Administrator::all();

        if($administrators->isEmpty()) {
            Administrator::create([
            	'name' => 'Administrator',
	            'email' => 'info@email.de',
	            'password' => Hash::make('insecure')
            ])
        }
    }
}

Damit Dein Seeder verwendet werden kann, musst Du ihn im DatabaseSeeder hinzufügen:

<?php

use IlluminateDatabaseSeeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        $this->call(AdminTableSeeder::class);
    }
}

Um den Seeder auszuführen wechselst Du in Dein Terminal und generierst Composers autoload neu:

$ composer dump-autoload

Danach führst Du das Seeding über Artisan aus:

$ php artisan db:seed

Du hast auch die Möglichkeit das Seeding während der Migrations durchzuführen:

$ php artisan migrate:refresh --seed

Integration des Logins

In diesem Abschnitt integrierst Du einen neuen Login Bereich für den neuen Benutzertyp. Der aktuell verfügbare Login-Bereich ist starr auf das User Model ausgerichtet und es logged auch nur diesen ein.

Um den Login für den Business User bereitzustellen musst Du nun einige Komponenten dses regulären Logins adaptieren und ein wenig anpassen. Du benötigst einen neuen Controller, einen eigenen AuthenticatsUsers Trait und eine eigene Login View. Des weitern musst Du noch die neuen Routen hinterlegen. All das gehen wir nun Schritt für Schritt durch.

Beginnen wir mit dem Trait. Lege den Ordner „Traits“ bei Deinen Auth Controlleren an (app/Http/Controllers/Auth/Traits). In diesem erstellst Du den Trait AuthenticatesBusinessUsers, der zunächst eine exakte Kopie des Traits AuthenticatesUsers aus dem Namespace „IlluminateFoundationAuth“ ist.

Im Trait passt Du zunächst den Namespace an und fügst die usings für „IlluminateFoundationAuthRedirectsUsers“ und „IlluminateFoundationAuthThrottlesLogins“ hinzu, damit die eingebundenen Traits weiterhin funktionieren.

Zum Schluss passt Du noch die showLoginForm und die verwendete Guard an. In folgendem Beispiel findest Du alle Anpassungen hervorgehoben:

<?php

namespace AppHttpControllersAuthTraits;

use IlluminateFoundationAuthRedirectsUsers;
use IlluminateFoundationAuthThrottlesLogins;
use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use IlluminateValidationValidationException;

trait AuthenticatesBusinessUsers
{
	use RedirectsUsers, ThrottlesLogins;

	/**
	 * Show the application's login form.
	 *
	 * @return IlluminateHttpResponse
	 */
	public function showLoginForm()
	{
		return view('businessuser.auth.login');
	}

	/**
	 * Handle a login request to the application.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @return IlluminateHttpRedirectResponse|IlluminateHttpResponse|IlluminateHttpJsonResponse
	 *
	 * @throws IlluminateValidationValidationException
	 */
	public function login(Request $request)
	{
		$this->validateLogin($request);

		// If the class is using the ThrottlesLogins trait, we can automatically throttle
		// the login attempts for this application. We'll key this by the username and
		// the IP address of the client making these requests into this application.
		if ($this->hasTooManyLoginAttempts($request)) {
			$this->fireLockoutEvent($request);

			return $this->sendLockoutResponse($request);
		}

		if ($this->attemptLogin($request)) {
			return $this->sendLoginResponse($request);
		}

		// If the login attempt was unsuccessful we will increment the number of attempts
		// to login and redirect the user back to the login form. Of course, when this
		// user surpasses their maximum number of attempts they will get locked out.
		$this->incrementLoginAttempts($request);

		return $this->sendFailedLoginResponse($request);
	}

	/**
	 * Validate the user login request.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @return void
	 */
	protected function validateLogin(Request $request)
	{
		$this->validate($request, [
			$this->username() => 'required|string',
			'password' => 'required|string',
		]);
	}

	/**
	 * Attempt to log the user into the application.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @return bool
	 */
	protected function attemptLogin(Request $request)
	{
		return $this->guard()->attempt(
			$this->credentials($request), $request->filled('remember')
		);
	}

	/**
	 * Get the needed authorization credentials from the request.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @return array
	 */
	protected function credentials(Request $request)
	{
		return $request->only($this->username(), 'password');
	}

	/**
	 * Send the response after the user was authenticated.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @return IlluminateHttpResponse
	 */
	protected function sendLoginResponse(Request $request)
	{
		$request->session()->regenerate();

		$this->clearLoginAttempts($request);

		return $this->authenticated($request, $this->guard()->user())
			?: redirect()->intended($this->redirectPath());
	}

	/**
	 * The user has been authenticated.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @param  mixed  $user
	 * @return mixed
	 */
	protected function authenticated(Request $request, $user)
	{
		//
	}

	/**
	 * Get the failed login response instance.
	 *
	 * @param  IlluminateHttpRequest $request
	 *
	 * @return void
	 */
	protected function sendFailedLoginResponse(Request $request)
	{
		throw ValidationException::withMessages([
			$this->username() => [trans('auth.failed')],
		]);
	}

	/**
	 * Get the login username to be used by the controller.
	 *
	 * @return string
	 */
	public function username()
	{
		return 'email';
	}

	/**
	 * Log the user out of the application.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @return IlluminateHttpResponse
	 */
	public function logout(Request $request)
	{
		$this->guard()->logout();

		$request->session()->invalidate();

		return $this->loggedOut($request) ?: redirect('/');
	}

	/**
	 * The user has logged out of the application.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @return mixed
	 */
	protected function loggedOut(Request $request)
	{
		//
	}

	/**
	 * Get the guard to be used during authentication.
	 *
	 * @return IlluminateContractsAuthStatefulGuard
	 */
	protected function guard()
	{
		return Auth::guard('business_user');
	}
}

Da nun der angepasste Trait zur Verfügung steht, kannst Du den LoginController kopieren und anpassen. In diesem änderst Du den verwendeten Trait zu AuthenticatesBusinessUsers und den „redirect to“ Pfad zu Deinem neuen Home-Verzeichnis. Im Konstruktor änderst Du die Guard der „guest“ Middleware auf „guest:business_user“ damit angemeldete Benutzer dieser Guard nicht auf die Login Seite gelangen.

Hier findest Du den Code des angepassten Controllers. Die Angepassten Stellen sind wieder markiert:

<?php

namespace AppHttpControllersAuth;

use AppHttpControllersAuthTraitsAuthenticatesBusinessUsers;
use AppHttpControllersController;

class LoginBusinessUserController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Login Business User Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles authenticating users for the application and
    | redirecting them to your home screen. The controller uses a trait
    | to conveniently provide its functionality to your applications.
    |
    */

    use AuthenticatesBusinessUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = 'business-user/home';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest:business_user')->except('logout');
    }
}

Nun fehlen noch die Einträge der Routen und die Login-View. Diese integrierst Du im nächsten Schritt. Damit ist die Anmeldung auch bereits komplett.
Öffne die Routes Datei „web.php“ und füge die beiden Login Routen GET & POST in die Gruppierung ein. Die hinzugefügten Routen sind hervorgehoben:

<?php

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Auth::routes(['verify' => true]);

Route::get('/home/', 'HomeController@index')
     ->name('home');

Route::prefix('business-user')->group(function () {
	Route::get('/home/', 'BusinessUserHomeController@index')
	     ->name('business_user.home');

	Route::get('/register/', 'AuthRegisterBusinessUserController@showRegistrationForm')
	     ->name('business_user.register.get');
	Route::post('/register/', 'AuthRegisterBusinessUserController@register')
	     ->name('business_user.register.post');

	Route::get('/email/resend/', 'BusinessUserVerificationController@resend')
	     ->name('business_user.verification.resend');
	Route::get('/email/verify/', 'BusinessUserVerificationController@show')
	     ->name('business_user.verification.notice');
	Route::get('/email/verify/{id}/', 'BusinessUserVerificationController@verify')
	     ->name('business_user.verification.verify');

	Route::get('/login/', 'AuthLoginBusinessUserController@showLoginForm')
	     ->name('business_user.login');
	Route::post('/login/', 'AuthLoginBusinessUserController@login')
	     ->name('business_user.login.submit');
});

Kopiere nächstes die Login View im „auth“ Verzeichnis und füge sie in dem neuen Verzeichnis Deines neuen Benutzertypes ein. Passe dort die Überschrift, sowie die Post-Action an. Damit hast Du alle relevanten Anpassungen für Deinen eigenen Login Beriech vorgenommen:

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Business User Login') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('business_user.login.submit') }}">
                        @csrf

                        <div class="form-group row">
                            <label for="email" class="col-sm-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ old('email') }}" required autofocus>

                                @if ($errors->has('email'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password" class="col-md-4 col-form-label text-md-right">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" name="password" required>

                                @if ($errors->has('password'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('password') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <div class="col-md-6 offset-md-4">
                                <div class="form-check">
                                    <input class="form-check-input" type="checkbox" name="remember" id="remember" {{ old('remember') ? 'checked' : '' }}>

                                    <label class="form-check-label" for="remember">
                                        {{ __('Remember Me') }}
                                    </label>
                                </div>
                            </div>
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-8 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Login') }}
                                </button>

                                <a class="btn btn-link" href="{{ route('password.request') }}">
                                    {{ __('Forgot Your Password?') }}
                                </a>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Damit Du nun alle Redirects korrekt zum Laufen bringst, müssen wir noch einige Dateien von Laravel Anpassen und umschreiben. Beginne nun damit die Authenticate Mittleware unter „app/Http/Middleware“ folgendermaßen Anzupassen:

<?php

namespace AppHttpMiddleware;

use Closure;
use IlluminateAuthMiddlewareAuthenticate as Middleware;

class Authenticate extends Middleware
{
	/**
	 * @var array
	 */
	protected $guards = [];

	/**
	 * Handle an incoming request.
	 *
	 * @param  IlluminateHttpRequest $request
	 * @param  Closure $next
	 * @param  string[] ...$guards
	 * @return mixed
	 *
	 * @throws IlluminateAuthAuthenticationException
	 */
	public function handle($request, Closure $next, ...$guards)
	{
		$this->guards = $guards;

		return parent::handle($request, $next, ...$guards);
	}

	/**
	 * Get the path the user should be redirected to when they are not authenticated.
	 *
	 * @param  IlluminateHttpRequest $request
	 * @return string
	 */
	protected function redirectTo($request)
	{
		if (!$request->expectsJson()) {
			if (array_first($this->guards) === 'business_user') {
				return route('business_user.login');
			}
			return route('login');
		}
	}
}

Danach öffnest Du die Klasse „RedirectIfAuthenticated“ im selben Verzeichnis und passt sie folgendermaßen an:

<?php

namespace AppHttpMiddleware;

use Closure;
use IlluminateSupportFacadesAuth;

class RedirectIfAuthenticated
{
    /**
     * Handle an incoming request.
     *
     * @param  IlluminateHttpRequest  $request
     * @param  Closure  $next
     * @param  string|null  $guard
     * @return mixed
     */
    public function handle($request, Closure $next, $guard = null)
    {
        if ($guard == "business_user" && Auth::guard($guard)->check()) {
            return redirect()->route('business_user.home');
        }

        if (Auth::guard($guard)->check()) {
            return redirect('/home');
        }

        return $next($request);
    }
}

Da „MustVerifyEmail“ im User und im BusinessUser integriert wurden, müssen auch diese Redirects angepasst werden. Dafür legst Du Deine eigene Middleware an, die dann von der Ilumniate Middleware erbt. Dadurch können wir die „handle“ Funktion anpassen, ohne die Vendor-Klasse zu verändern.
Lege nun die Middleware im Terminal an:

$ php artisan make:middleware EnsureEmailIsVerified

Passe nun die erzeugte Middleware folgendermaßen an:

<?php

namespace AppHttpMiddleware;

use Closure;
use IlluminateAuthMiddlewareEnsureEmailIsVerified as Middleware;
use IlluminateContractsAuthMustVerifyEmail;
use IlluminateHttpRequest;
use IlluminateSupportFacadesAuth;
use IlluminateSupportFacadesRedirect;

class EnsureEmailIsVerified extends Middleware
{
	/**
	 * Handle an incoming request.
	 *
	 * @param  IlluminateHttpRequest $request
	 * @param  Closure $next
	 * @param  null $guard
	 *
	 * @return IlluminateHttpResponse|IlluminateHttpRedirectResponse
	 */
	public function handle($request, Closure $next, $guard = null)
	{
		/** @var array $config */
		$configGuards = array_keys(config('auth.guards'));

		/** @var string $guard */
		foreach($configGuards as $configGuard) {
			/** @var string $guardFunction */
			$guardFunction = sprintf('handleGuard_%1$s', $configGuard);

			if(method_exists($this, $guardFunction)) {
				/** @var null|IlluminateHttpRedirectResponse $result */
				$result = $this->{$guardFunction}($configGuard, $request);

				if($result instanceof IlluminateHttpRedirectResponse) {
					return $result;
				}
			}
		}

		return $next($request);
	}

	/**
	 * Behandelt Requests der "web" Guard.
	 *
	 * @param string $guard
	 * @param Request $request
	 *
	 * @return IlluminateHttpRedirectResponse|void
	 */
	private function handleGuard_web($guard, $request)
	{
		if (Auth::guard($guard)->check()) {
			if (! Auth::guard($guard)->user() ||
			    (Auth::guard($guard)->user() instanceof MustVerifyEmail &&
			     ! Auth::guard($guard)->user()->hasVerifiedEmail())) {
				return $request->expectsJson()
					? abort(403, 'Your email address is not verified.')
					: Redirect::route('verification.notice');
			}
		}
	}

	/**
	 * Behandelt Requests der "business_user" Guard.
	 *
	 * @param string $guard
	 * @param Request $request
	 *
	 * @return IlluminateHttpRedirectResponse|void
	 */
	private function handleGuard_business_user($guard, $request)
	{
		if (Auth::guard($guard)->check()) {
			if (! Auth::guard($guard)->user() ||
			    (Auth::guard($guard)->user() instanceof MustVerifyEmail &&
			     ! Auth::guard($guard)->user()->hasVerifiedEmail())) {
				return $request->expectsJson()
					? abort(403, 'Your email address is not verified.')
					: Redirect::route('business_user.verification.notice');
			}
		}
	}
}

Damit die neue Middleware „EnsureEmailIsVerified“ verwendet wird musst Du diese im Kernel hinterlegen. Öffne die Klasse Kernel im Verzeichnis „app/Http“ und passe die Klasse in der routeMiddleware Property zum Key „verified“ an. Hinterlege hier Deine neue Klasse der Middleware „AppHttpMiddlewareEnsureEmailIsVerified::class“.

/**
 * The application's route middleware.
 *
 * These middleware may be assigned to groups or used individually.
 *
 * @var array
 */
protected $routeMiddleware = [
	'auth' => AppHttpMiddlewareAuthenticate::class,
	'auth.basic' => IlluminateAuthMiddlewareAuthenticateWithBasicAuth::class,
	'bindings' => IlluminateRoutingMiddlewareSubstituteBindings::class,
	'cache.headers' => IlluminateHttpMiddlewareSetCacheHeaders::class,
	'can' => IlluminateAuthMiddlewareAuthorize::class,
	'guest' => AppHttpMiddlewareRedirectIfAuthenticated::class,
	'signed' => IlluminateRoutingMiddlewareValidateSignature::class,
	'throttle' => IlluminateRoutingMiddlewareThrottleRequests::class,
	'verified' => AppHttpMiddlewareEnsureEmailIsVerified::class,
];

Die letzte benötigte Anpassung ist das Error Handling für den „Unauthenticated“ Error und den nachfolgenden Redirect. Dazu fügst Du in der Handler Klasse „app/Exceptions/Handler.php“ folgende Funktion ein:

/**
 * Convert an authentication exception into a response.
 *
 * @param  IlluminateHttpRequest  $request
 * @param  IlluminateAuthAuthenticationException  $exception
 * @return IlluminateHttpResponse
 */
protected function unauthenticated($request, AuthenticationException $exception)
{
	if ($request->expectsJson()) {
		return response()->json(['error' => 'Unauthenticated.'], 401);
	}

	/** @var string $guard */
	$guard = array_get($exception->guards(),0);

	switch($guard) {
		case 'business_user':
			$login = 'business_user.login';
			break;

		default:
			$login = 'login';
			break;
	}

	return redirect()->route($login);
}

Der Login an der Plattform und die Weiterleitungen sollten nun ohne Probleme funktionieren. Im nächsten Schritt schließen wir dieses Tutorial ab. Es fehlt nur noch die Funktion „Passwort vergessen“.

Integration der Passwort vergessen Funktion

Um die Integration des eigenen Benutzertyps abzuschließen, zeige ich Dir in diesem Schritt, wie Du die „Passwort vergessen“ Funktion für Deinen eigenen Benutzertyp integrierst. Dazu musst Du die bestehenden Controller, Views und E-Mail Vorlagen adaptieren. Zuletzt müssen wir noch die neuen Routen bereitstellen.

Beginnen wir nun mit den Views. Kopiere das „passwords“ Verzeichnis mit den Views aus dem Verzeichnis „resources/views/auth“ und füge sie in Deinem View Ordner ein. In meinem Beispiel ist das „resources/vies/businessuser“. Nun passe noch die Routen der Formulare an Deine späteren Routen an.

Dieser Schritt bedarf keiner weiteren Erläuterung. Nachfolgend findest Du aber noch mals die neuen Views.

@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Reset Password') }}</div>

                <div class="card-body">
                    @if (session('status'))
                        <div class="alert alert-success" role="alert">
                            {{ session('status') }}
                        </div>
                    @endif

                    <form method="POST" action="{{ route('business_user.password.email') }}">
                        @csrf

                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ old('email') }}" required>

                                @if ($errors->has('email'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Send Password Reset Link') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row justify-content-center">
        <div class="col-md-8">
            <div class="card">
                <div class="card-header">{{ __('Reset Password') }}</div>

                <div class="card-body">
                    <form method="POST" action="{{ route('business_user.password.update') }}">
                        @csrf

                        <input type="hidden" name="token" value="{{ $token }}">

                        <div class="form-group row">
                            <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

                            <div class="col-md-6">
                                <input id="email" type="email" class="form-control{{ $errors->has('email') ? ' is-invalid' : '' }}" name="email" value="{{ $email ?? old('email') }}" required autofocus>

                                @if ($errors->has('email'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('email') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password" class="col-md-4 col-form-label text-md-right">{{ __('Password') }}</label>

                            <div class="col-md-6">
                                <input id="password" type="password" class="form-control{{ $errors->has('password') ? ' is-invalid' : '' }}" name="password" required>

                                @if ($errors->has('password'))
                                    <span class="invalid-feedback" role="alert">
                                        <strong>{{ $errors->first('password') }}</strong>
                                    </span>
                                @endif
                            </div>
                        </div>

                        <div class="form-group row">
                            <label for="password-confirm" class="col-md-4 col-form-label text-md-right">{{ __('Confirm Password') }}</label>

                            <div class="col-md-6">
                                <input id="password-confirm" type="password" class="form-control" name="password_confirmation" required>
                            </div>
                        </div>

                        <div class="form-group row mb-0">
                            <div class="col-md-6 offset-md-4">
                                <button type="submit" class="btn btn-primary">
                                    {{ __('Reset Password') }}
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>
@endsection

Nun duplizierst Du die Controller „ForgotPasswordController“, sowie „ResetPasswordController“ und gibt’s ihnen einen neuen Namen. Im neuen „ResetPasswordController“ passt Du den RedirectTo Pfad an und den beiden neuen Controllern die Middleware im Konstruktor. Wir werden in den Controllern mit dem Password Broker von Laravel arbeiten. Dieser verwaltet alles rund um die Passwörter des Benutzers und ist wieder festgelegt auf das User Model.
In den duplizierten Controllern müssen nun einige Funktionen integriert werden um die verwendeten Traits zu überschreiben. Im folgenden findest Du den angepassten „ForgotPasswordController“:

<?php

namespace AppHttpControllersAuth;

use AppHttpControllersController;
use IlluminateFoundationAuthSendsPasswordResetEmails;
use IlluminateSupportFacadesPassword;

class ForgotBusinessUserPasswordController extends Controller
{
	/*
	 |--------------------------------------------------------------------------
	 | Password Reset Controller
	 |--------------------------------------------------------------------------
	 |
	 | This controller is responsible for handling password reset emails and
	 | includes a trait which assists in sending these notifications from
	 | your application to your users. Feel free to explore this trait.
	 |
	 */

	use SendsPasswordResetEmails;

	/**
	 * Create a new controller instance.
	 *
	 * @return void
	 */
	public function __construct()
	{
		$this->middleware('guest:business_user');
	}

	/**
	 * Get the broker to be used during password reset.
	 *
	 * @return IlluminateContractsAuthPasswordBroker
	 */
	protected function broker()
	{
		return Password::broker('business_users');
	}

	/**
	 * Display the form to request a password reset link.
	 *
	 * @return IlluminateHttpResponse
	 */
	public function showLinkRequestForm()
	{
		return view('businessuser.passwords.email');
	}
}

Im neuen „ForgotPasswordController“ wurde der Passwort Namespace von Laravel hinzugefügt, damit wir Zugriff auf den Passwort Broker haben. Danach wurden die beiden Trait-Funktionen „broker“ und „showLinkRequestForm“ überschrieben. Es ist für diesen Schritt übrigens sehr wichtig, dass Du in der „auth“ Konfiguration den Knoten für Passwort mit Deinem neuen User Provider befüllt hast, wie es zu beginn dieses Tutorials erklärt wurde. Ansonsten Kommt es im Broker zu Problemen. Dadurch dass der Passwort Broker nun den korrekten Provider übergeben bekommen hat, kümmert er sich um alle relevenaten Schritte des Passwort-Managements.

Und hier folgt noch der zweite Controller „ResetPasswordController“:

<?php

namespace AppHttpControllersAuth;

use AppHttpControllersController;
use IlluminateFoundationAuthResetsPasswords;
use IlluminateHttpRequest;
use IlluminateSupportFacadesPassword;
use IlluminateSupportFacadesAuth;

class ResetBusinessUserPasswordController extends Controller
{
	/*
	 |--------------------------------------------------------------------------
	 | Password Reset Controller
	 |--------------------------------------------------------------------------
	 |
	 | This controller is responsible for handling password reset requests
	 | and uses a simple trait to include this behavior. You're free to
	 | explore this trait and override any methods you wish to tweak.
	 |
	 */

	use ResetsPasswords;

	/**
	 * Where to redirect users after resetting their password.
	 *
	 * @var string
	 */
	protected $redirectTo = '/business-user/home';

	/**
	 * Create a new controller instance.
	 *
	 * @return void
	 */
	public function __construct()
	{
		$this->middleware('guest:business_user');
	}

	/**
	 * Display the password reset view for the given token.
	 *
	 * If no token is present, display the link request form.
	 *
	 * @param  IlluminateHttpRequest  $request
	 * @param  string|null  $token
	 * @return IlluminateContractsViewFactory|IlluminateViewView
	 */
	public function showResetForm(Request $request, $token = null)
	{
		return view('business_user.passwords.reset')->with(
			['token' => $token, 'email' => $request->email]
		);
	}

	/**
	 * Get the guard to be used during password reset.
	 *
	 * @return IlluminateContractsAuthStatefulGuard
	 */
	protected function guard()
	{
		return Auth::guard('business_user');
	}

	/**
	 * Get the broker to be used during password reset.
	 *
	 * @return IlluminateContractsAuthPasswordBroker
	 */
	protected function broker()
	{
		return Password::broker('business_users');
	}
}

Genauso wie im ersten Controller wurden hier einige Trait-Funktionen überschrieben, damit der korrekte Broker, sowie die korrekte Guard verwendet werden.

Als nächstes betrachten wir die Anpassung der E-Mail Notification. Diese wird nicht über die Controller gesteuert, sondern über den versteckten Trait „CanResetPassword“ der in der User Klasse mit dem Namespace „IlluminateFoundationAuth“ integriert wurde. Deine neuer Benutzertyp verwendet also ebenfalls diesen Trait und verfügt somit über die Funktion „sendPasswordResetNotification“, welche die E-Mail zum zurücksetzten des Passworts verschickt. Leider ist auch diese auf das normale User Model gemünzt, was aber kein Problem darstellt, da wir die Funktion des Traits nun einfach überschreiben.

Erzeuge zunächst eine neue Notification mittels Artisan:

$ php artisan make:notification ResetBusinessUserPasswordNotification

Die Notification passt Du nun folgendermaßen an:

<?php

namespace AppNotifications;

use IlluminateBusQueueable;
use IlluminateSupportFacadesLang;
use IlluminateNotificationsNotification;
use IlluminateContractsQueueShouldQueue;
use IlluminateNotificationsMessagesMailMessage;

class ResetBusinessUserPasswordNotification extends Notification implements ShouldQueue
{
	use Queueable;

	/**
	 * The password reset token.
	 *
	 * @var string
	 */
	public $token;

	/**
	 * The callback that should be used to build the mail message.
	 *
	 * @var Closure|null
	 */
	public static $toMailCallback;

	/**
	 * Create a notification instance.
	 *
	 * @param  string  $token
	 * @return void
	 */
	public function __construct($token)
	{
		$this->token = $token;
	}

	/**
	 * Get the notification's delivery channels.
	 *
	 * @param  mixed  $notifiable
	 * @return array
	 */
	public function via($notifiable)
	{
		return ['mail'];
	}

	/**
	 * Build the mail representation of the notification.
	 *
	 * @param  mixed  $notifiable
	 * @return IlluminateNotificationsMessagesMailMessage
	 */
	public function toMail($notifiable)
	{
		if (static::$toMailCallback) {
			return call_user_func(static::$toMailCallback, $notifiable, $this->token);
		}

		return (new MailMessage)
			->subject(Lang::getFromJson('Reset Password Notification'))
			->line(Lang::getFromJson('You are receiving this email because we received a password reset request for your account.'))
			->action(Lang::getFromJson('Reset Password'), url(config('app.url').route('business_user.password.reset', $this->token, false)))
			->line(Lang::getFromJson('If you did not request a password reset, no further action is required.'));
	}

	/**
	 * Set a callback that should be used when building the notification mail message.
	 *
	 * @param  Closure  $callback
	 * @return void
	 */
	public static function toMailUsing($callback)
	{
		static::$toMailCallback = $callback;
	}

	/**
	 * Get the array representation of the notification.
	 *
	 * @param  mixed  $notifiable
	 * @return array
	 */
	public function toArray($notifiable)
	{
		return [
			//
		];
	}
}

Die neue Notification nimmt genau wie die Alte einen Token entgegen, über den die Plattform später den Benutzer identifizieren kann. Zusätzlich wird in der Url der korrekte Pfad auf die neue Reset View hinterlegt.

Öffne nun Dein neues User-Model und füge folgende Funktion hinzu:

<?php

namespace App;

use AppNotificationsResetBusinessUserPasswordNotification;
use IlluminateNotificationsNotifiable;
use IlluminateContractsAuthMustVerifyEmail;
use IlluminateFoundationAuthUser as Authenticatable;

class BusinessUser extends Authenticatable implements MustVerifyEmail
{
	use Notifiable;

	protected $guard = 'business_user';

	/**
	 * The attributes that are mass assignable.
	 *
	 * @var array
	 */
	protected $fillable = [
		'name',
		'email',
		'password',
		'company',
		'vat',
		'street_name',
		'street_number',
		'city',
		'zip'
	];

	/**
	 * The attributes that should be hidden for arrays.
	 *
	 * @var array
	 */
	protected $hidden = [
		'password', 'remember_token',
	];

	/**
	 * Versendet eine E-Mail-Verifizierungs Benachrichtigung an den Benutzer
	 */
	public function sendEmailVerificationNotification()
	{
		$this->notify(new NotificationsBusinessUserVerifyEmail);
	}

	/**
	 * Send the password reset notification.
	 *
	 * @param  string  $token
	 * @return void
	 */
	public function sendPasswordResetNotification($token)
	{
		$this->notify(new ResetBusinessUserPasswordNotification($token));
	}
}

Die letzte Änderung, die Du nun noch vornehmen musst, ist die Änderung des Links in Deiner neuen Login-View. Dort ist noch die alte Route zum „Passwort vergessen“ des normalen User-Models hinterlegt. Öffne nun die View und passe die Route darin an:

<a class="btn btn-link" href="{{ route('business_user.password.request') }}">
    {{ __('Forgot Your Password?') }}
</a>

Zusammenfassung

In diesem etwas umfangreicheren Tutorial hast Du gelernt, wie Du Deinen eigenen Benutzertypen ergänzend zum normalen Laravel User Model integrieren kannst. Du hast zu Beginn den Unterschied zwischen einer Benutzerrolle und einem Benutzertypen kennengelernt. Direkt im Anschluss hast Du die normale Laravel Authentication integriert und migriert.
Basierend auf dem normalen User Model hast Du Dein eigenes User Model samt Migration angelegt. Um später mit der Zugriffsbeschränkung und den Controllern arbeiten zu können hast Du in der „auth“ Konfiguration von Laravel Deine Guard, Deinen neuen Provider und die Konfiguration des eigenen Passwort Brokers hinterlegt.
Mittels der „auth“ Middleware von Laravel hast Du erfolgreich Deine Controller vor dem unbefugten Zugriff geschützt und sichergestellt, dass nur Benutzer mit dem neuen Benutzertyp in das zugehörige Backend gelangen.
Im Anschluss an die Zugriffsbeschränkung hast Du entweder die Registrierung Deines neuen Benutzertypes oder das Database Seeding integriert, je nachdem, ob sich Dein neuer Benutzertyp registrieren darf. Wenn Du die Registrierung integriert hast, hast Du ebenfalls gelernt, wie Du Deine eigene E-Mail Verifizierung nach dem Vorbild von Laravel 5.7 integrierst.
Nach der Registrierung oder dem Database Seeding habe ich Dir gezeigt, wie Du Deinen eigenen Login Controller samt Views einbaust. Somit können sich Deine Benutzer des neuen Benutzertypes an Deiner Plattform anmelden.
Last but not least hast Du die Passwort vergessen Funktion integriert und Dich mit dem Passwort Broker von Laravel auseinandergesetzt.

Fragen und Anregungen

Solltest Du Fragen, Anregungen oder Ergänzungen zum Artikel haben, zögere nicht mir diese in einem Kommentar mitzuteilen.


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.


Task_Queue_und_CSV_Import_Laravel

Task Queue & CSV Import mit Laravel 5.5 – so funktioniert's

Task oder Job Queues sind ein bewährtes Mittel um komplexe Aufgaben asynchron auszuführen und den Benutzer einer Anwendung nicht unnötig vor einer ladenden Website oder „eingefrorenen“ Software warten zu lassen. Ein weiterer, nicht zu unterschätzender Vorteil ist Auslagerung möglichererweise rechenintensiver Operationen in andere Prozesse, die nichts mit dem Webserver zutun haben.

Das PHP Framework Laravel liefert in seinem Funktionsumfang ebenfalls eine Queue-Funktionalität mit und gibt seinen Nutzeren auf diesem Weg ein Werkzeug an die Hand, das Nutzungsverhalten von komplexeren Laravel-Projekten stark zu verbessern.

In diesem Artikel befasse ich mich mit der Herausforderung einen Importer für CSV-Dateien in Laravel zu integrieren. Die Besonderheit ist, dass diese CSV Datein ungf. 50000 – 100000 Datensätze beinhalten können und der „Echtzeit-Import“ somit nicht möglich ist, schließlich kann der Benutzer der Anwendung nicht 30-60 Minuten warten, bis die Website wieder reagiert.

Für das „Abarbeiten“ der CSV-Datensätze werde ich kein Plugin verwenden. Ich entwickle gemeinsam mit Dir eigene kleine Code–Schnippsel, um CSV-Dateien Datensatz für Datensatz auszulesen und die beinhalteten Daten in eine Datenbank zu speichern.

Was ist eine Task Queue?

Eine Task Queue ist im Grunde eine Liste an Aufgaben, die sequenziell abgearbeitet wird. Welche Aufgabe als nächstes bearbeitet wird hängt von unterschiedlichen Kriterien ab. Der einfachste Fall ist „first in, first out“, also „wer zuerst kommt, mahlt zuerst“. Die Aufgabe die sich zuerst an unserer Queue angemeldet hat, wird auch als erstes bearbeitet. Weiterhin ist es möglich die Aufgaben mit weiteren Parametern zu versehen, wie bspw. einer Priorität, oder einem Wert für die Zeit, die die Aufgabe schon in der Queue verbringt, damit nicht nur Jobs einer höheren Priorität bearbeitet werden.

Queues die auf Prioritäten basieren haben aber eine andere Struktur als einfache Queues. Bei diesem Vorgehen gibt es für jede Prioriät eine eigene Queue. Verändert sich also die Priorität eines Jobs, wird er in eine andere Queue verschoben. Damit befassen ich mich aber in diesem Artikel noch nicht.

Betrachten wir nun erst mal die typischen Rollen, die in einer Queue agieren. Typische Rollen in einem Queue System sind der „Dispatcher“, der die Aufgaben entgegennimmt und sie gegebenenfalls parametrisiert in die Queue einfügt, die Queue als solche, der Queue Worker, der immer die Aufgabe aus der Queue entnimmt, die als nächstes an der Reihe ist und „last but not least“ der Code, der die Aufgabe verarbeitet. In unserem Beispiel ist das der Import einer großen CSV-Datei.

Nachfolgend findest Du eine Grafik, die den Aufbau eines Queue Systems grob skizziert:

Darstellung der Rollen in einer Task Queue von Laravel

 

Durch diesen Ablauf wird gewährleistet, dass der Webserver durch die komplexen Operationen nicht überlastet wird. Jeder ausstehende Import wird der Reihe nach abgearbeitet.
In der folgenden Grafik habe ich noch mal grob skizziert, wie die Rollenvertreilung in einer priorisierten Queue aufgebaut ist.

Darstellung der Rollen in einer priorisierten Task Queue von Laravel

Wie wird die Task Queue in Laravel integriert?

Laravel bietet zu diesem Thema, wie auch zu vielen anderen Themen, eine sehr gute Dokumentation unter: Laravel 5.5 - Queues. Beginnen wir erst mal mit der Konfiguration. Im Verzeichnis „config“ Deiner Laravel-Installation findest Du die Queue Konfiguration „queue.php“. In dieser Datei Konfigurierst Du die Queue-Connection. Laravel bietet die Möglichkeit, Deine Queues in unterschiedlichen Systemen zu hinterlegen, wie unteranderem einer Datenbank, Amazons SQS, oder auch Redis. Wie Du Paypal in Laravel integrierst, erfährst Du hier.

In diesem Beitrag entscheide ich mich für den Connection-Typ „database“. Du kannst auch gerne eine andere Variante wählen. Ich gehe im Zuge dieses Beitrags aber lediglich auf die Variante Datenbank ein, da Performance in diesem Fall kein Kriterium ist.

Um nun die Datenbankmigration vorzubereiten, die Dir die benötigten Tabellenstrukturen für die Verwaltung der Jobs bereitstellt, verwendest Du Laravels CLI artisan.

$ php artisan queue:table

Die erstellte Migration integrierst Du über den Befehl „migrate“.

$ php artisan migrate

Die Integration der Job Klasse ist ebenfalls sehr simpel. Du kannst über den „make:job“ Befehl ein Template erzeugen lassen.

$ php artisan make:job ImportCsvFile

Das erzeugte Template sieht nun in etwas wie folgt aus:

<?php

namespace AppJobs;

use IlluminateBusQueueable;
use IlluminateQueueSerializesModels;
use IlluminateQueueInteractsWithQueue;
use IlluminateContractsQueueShouldQueue;
use IlluminateFoundationBusDispatchable;

class ImportCsvFile implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $csvFilePath;

    /**
     * Create a new job instance.
     *
     * @param  string  $csvFilePath
     * @return void
     */
    public function __construct($csvFilePath)
    {
        $this->csvFilePath = $csvFilePath;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        // Importieren des CSV Datei
    }
}

Jobs verfügen über die Implementierung gegen das Interface ShouldQueue über die statische Methode dispatch. Diese ermöglicht es, den Job an die Queue anzuhängen. Als Parameter erwartet die Methode den CSV Pfad der Datei.

Integration des CSV-Import Controllers

Um das Beispiel etwas greifbarer zu machen zeige ich Dir nun noch, wie Du einen passenden Controller bereitstellst, der den Dispatcher später ausführt. Dafür erstellst Du den Controller „CsvImportController“ über artisan.

$ php artisan make:controller CsvImportController

Im folgen Beispiel führe ich drei Wege an, über die Du den dispatcher ansteuern kannst.

  • Direkt
  • Verzögert
  • Mit anderen Jobs verkettet
<?php

namespace AppHttpControllers;

use AppJobsImportCsvFile;
use IlluminateHttpRequest;
use IlluminateSupportFacadesStorage;

class CsvImportController extends Controller
{
    public function index () {
        return view('import.csvfile');
    }

    public function store (Request $request) {

        // CSV Datei hochladen und temporär speichern
        $csvFilePath = Storage::putFile('uploads', $request->file('csvfile'));

        // Import dispatchen
        ImportCsvFile::dispatch($csvFilePath);


        /* ### Weitere dispatch Optionen ## */

        // Mit Verzögerung
        // -------------------------------------
        // ImportCsvFile::dispatch($csvFilePath)
        //      ->delay(now()->addMinutes(10));


        // Job Verkettung
        // -------------------------------------
        // ImportCsvFile::withChain([
        //      new ValidateCsvFile,
        //      new ImportCsvFileToDatabase
        // ])->dispatch();
    }
}

Integration der CSV-Import View und Route

Die View (csvfile.blade.php) ist in diesem Beispiel minimalistisch dargestellt. Sie enthält lediglich ein Formular um die Datei hochzuladen. Als action ist die „named route“ → „csvimport.store“ hinterlegt, die Du im nachfolgenden Schritt integrierst.

<h2>CSV Importieren</h2>

<form action="{{ route('csvimport.store') }}" method="post" enctype="multipart/form-data">

    {{ csrf_field() }}

    <label for="csvfile">Bitte wählen Sie eine CSV Datei aus</label>

    <input type="file" name="csvfile" id="csvfile"> <br />
    
    <button type="submit">Datei importieren</button>
    
</form>

Die Routen trägst Du in der „web.php“ unter dem Verzeichnis „routes“ ein.

Route::get('/csvimport', 'CsvImportController@index')->name('csvimport.index');

Route::post('/csvimport/store', 'CsvImportController@store')->name('csvimport.store');

Ein Blick auf die CSV Datei und ihr Model

Die CSV Datei in diesem Beispiel ist ebenfalls minimalistisch aufgebaut. Ich habe mich für eine simple „Sales“ CSV Datei (Demo: "task_queue_sales.csv") entschieden, die über die Spalten „day“ und „revenue“ verfügt. Zunächst erstellst Du für das Beispiel das Model „Sales“ und die zugehörige Migrationsdatei über folgenden Befehl.

$ php artisan make:model Sales -m

Im erzeugten Model hinterlegst Du nun noch die beiden Properties „table“ und „fillable“.

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Sales extends Model
{
    protected $table = 'sales';

    protected $fillable = ['day', 'revenue'];
}

Nun erzeugst Du noch eine Migration um die Spalten „day“ und „revenue“ zum Sales Model hinzuzufügen.

$ php artisan make:migration add_properties_to_sales

Die Migrationsdatei passt Du nun folgendermaßen an.

<?php

use IlluminateSupportFacadesSchema;
use IlluminateDatabaseMigrationsMigration;

class AddPropertiesToSales extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('sales', function($table) {
            $table->date('day')->unique();
            $table->float('revenue');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('sales', function($table) {
            $table->dropColumn('day');
            $table->dropColumn('revenue');
        });
    }
}

Nun gilt es die Migrationen anzulegen. Dazu verwendest Du den Befehl Migrate. Dazu ist es aber wichtig, dass Du die Datenbankverbindung in Laravel konfiguriert hast.

$ php artisan migrate

Nach einer erfolgreichen Migration sind die vorbereiteten Spalten in der Tabelle „sales“ hinterlegt. Wir können nun damit beginnen das Model in den CSV Importer zu integrieren. Dazu öffnest Du nun wieder die angelegte Job Klasse „ImportCsvFile“. In der „handle“ Methode hinterlegst Du nun den Code um die gespeicherte CSV Datei zeilenweise auszulesen und in die passenden Models anzulegen. Wichtig ist ebenfalls, dass Du den Namespace des Sales Models einträgst.

<?php

namespace AppJobs;

use AppSales;
use Exception;
use IlluminateBusQueueable;
use IlluminateQueueSerializesModels;
use IlluminateQueueInteractsWithQueue;
use IlluminateContractsQueueShouldQueue;
use IlluminateFoundationBusDispatchable;
use IlluminateSupportFacadesStorage;

class ImportCsvFile implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $csvFilePath;

    /**
     * Create a new job instance.
     *
     * @param  string  $csvFilePath
     * @return void
     */
    public function __construct($csvFilePath)
    {
        $this->csvFilePath = $csvFilePath;
    }

    /**
     * Execute the job.
     *
     * @throws Exception
     * @return void
     */
    public function handle()
    {
        if (!Storage::disk('local')->exists($this->csvFilePath))
            throw new Exception(sprintf(
                'Die CSV Datei konnte nicht unter dem angegebenen Dateipfad gefunden werden. Dateipfad: "%1$s"'
                , $this->csvFilePath
            ));

        // Importieren der CSV Datei
        $fileData = Storage::get($this->csvFilePath);
        $csvData = str_getcsv($fileData, "n");

        foreach ($csvData as &$row) {
            $row = str_getcsv($row, ",");

            if(is_array($row) && sizeof($row) == 2) {
                $day = date('Y-m-d', strtotime($row[0]));
                Sales::create([
                    'day' => $day,
                    'revenue' => $row[1]
                ]);
            }
        }
    }
}

Der in der „handle“ Methode integrierte Code ist sehr simpel und um alles kurz und knapp zu halten, nicht refaktorisiert. Diverse Blöcke lassen sich wunderbar auslagern. Desweiteren sollten an mehreren Stellen Validierungen integriert werden.

Im ersten Abschnitt der Methode validiere Ich lediglich kurz, ob die Datei im „storage“ Ordner hinterlegt wurde. Ist dass der Fall, dann wird deren Inhalt geladen und mit der PHP Methode „str_getcsv()“ als Array geparsed.

Im letzten Abschnitt werden alle Zeilen der CSV Datei durchlaufen und die Daten jeder Zeile als Sale in der Datenbank gespeichert.

Queue Listener starten

Damit die Queue nun auch abgearbeitet wird, muss der Queue Listener gestartet werden.

$ php artisan queue:listen

Im Grunde stützt sich die Abarbeitung der Queue auf diesen CLI Prozess. Sollte die Queue auf einem Server eingerichtet werden und auch nach einem Fehler oder Serverneustart wieder erreichbar sein, so lohnt es sich für Dich einen Blick auf das Programm Supervisor zu werfen. Dieses läuft unter Linux Betriebssystemen und kann schnell installiert und konfiguriert werden. Laravel bietet in der Dokumentation ein Kapiel zu Supervisor an Laravel 5.5 - Supervisor configuration.

Fazit

In diesem Blogeintrag hast Du gelernt, wie Du Deine eigene Queue gestalten und entwickeln kannst. Du hast zu Beginn des Artikels die Queue in Deinem Laravel Projekt eingerichtet und hast Deinen eigenen Queue Job erstellt. Gegen ende des Artikels hast Du noch erfahren, wie Du aus dem Queue Job heraus eine CSV Datei in Deine Datenbank importieren kannst.

Solltest Du Fragen oder Anmerkungen haben, zögere nicht einen Kommentar zu hinterlassen.


Unit Testing in Laravel 5.5

Unit Testing in Laravel 5.5 – Unit Tests mit PHPUnit

Unit Testing ist wichtig und das scheint Dir schon bewusst zu sein, da Du es bis zu diesem Artikel geschafft hast. Die Gründe Unit Tests in Softwareprojekte zu integrieren wurden schon oft erläutert und sind Dir vormutlich auch bereits bekannt. In diesem Artikel werde ich Dir zeigen, wie Du Unit Tests mit dem PHP Test-Framework PHPUnit 6.4 in dem PHP Framwork Laravel 5.5 integrierst. Du wirst in Deinem Laravel Projekt über Artsian Unit Tests anlegen und diese mit Test-Logik befüllen.

Danach zeige ich Dir noch, wie Du automatisch ansehliche Code Coverage Reports für Dein Laravel Projekt generieren kannst, und dass alles mit Hilfe von PHPUnit. So hast Du immer die Testabdeckung Deines PHP Codes im Auge. Zum Schluss ergänze ich noch einige Fehler, die auftreten können, wo deren Ursprung oft nicht leicht zu ermitteln ist.

Einführung in das Unit Testing in Laravel

Laravel liefert von Haus aus Unit Testing und Integrationstest (Feature Tests) Methoden aus. Das starke PHP Test Framework PHPUnit ist fest in Laravel integriert und wird in der aktuellen Laravel Version 5.5 mit der Version 6.4 ausgeliefert. PHPUnit 6.4 unterstützt PHP 7.0 und PHP 7.1.

PHPUnit wird über den PHP Abhängigkeitsmanager Composer ausgeliefert und liegt nach dem „install“ Befehl im Verzeichnis „vendor/bin/“ (phpunit.php).
Ebenfalls mit an Board ist eine von Laravel bereitgestellte PHPUnit Konfigurationsdatei „phpunit.xml“. Diese liegt im Basisverzeichnis Deines Projektes. Über die „phpunit.xml“ kannst Du „environment“ Variablen überschreiben, „Testsuites“ für unterschiedliche Test-Kategorien anlegen und vieles mehr. Hier wirst Du später auch den automatischen PHP Code Coverage Report konfigurieren.

Laravel stellt in der „phpunit.xml“ Konfiguration bereits Test-Suites für Unit Tests und Integrationstests (Feature Tests) bereit. In Deinem Laravel-Projekt findest Du im Verzeichnis „tests“ bereits die beiden Test-Suite Verzeichnisse „Unit“ und „Feature“ in denen Du später die jeweiligen Tests hinterlegen kannst.
Im „tests“ Verzeichnis sind neben den Test-Suites auch die zwei Klassen „TestCase.php“ und „CreatesApplication.php“ hinterlegt. Diese „booten“ für Dich während der Tests die Laravel-Anwendung. Näheres dazu aber in den folgenden Abschnitten.

Laravel Testumgebung

Wenn Du Deine Tests mit PHPUnit ausführst, läuft die Laravel-Anwendung durch die überschreibenden Environment Variablen der „phpunit.xml“ im „Test-Modus“. Innerhalb dieses „Test-Modus“ ändert Laravel automatisch den Cache Treiber in den Modus „array“. Der Treiber dient zur Cache und Session-Verwaltung. Den Treiber kannst Du auch selber anpassen. Du findest ihn im Verzeichnis „config“ unter dem Name „cache.php“. Der „array“ Modus sorgt dafür, dass der Cache und die Sessions nicht persistent gespeichert werden (d.h. in der Datenbank oder im Dateisystem).

Du kannst die Konfiguration nach Deinen Wünschen anpassen und um weitere Einstellungen erweitern. Wichtig ist nur, dass Du nach vorgenommenen Änderungen den Laravel Konfig-Cache leerst. Dies kannst Du mit Artisan über den Befehl „config:clear“ tun:

$ php artisan config:clear

Einen Unit Test in Laravel anlegen und Tests ausführen

Wie bei fast allem hilft Dir auch hier das Laravel Tool Artisan aus. Du kannst den „make“ Befehl mit dem Parameter „test“ ausführen und einen Namen für den Test angeben. Wie das in der Konsole aussieht siehst Du anhand des folgendne Beispiels:

$ php artisan make:test FooBarTest --unit

Über den Zusatz „--unit“ wird ein Unit Test angelegt, lässt man ihn weg erhält man einen Integrationstest / Feature Test. Der Test wurde im jeweiligen Ordner angelegt. In diesem Beispiel wurde der Test im Unit Test Ordner abgelegt.
Wenn Du den Test öffnest kannst Du mit Hilfe aller PHPUnit Funktionen Unit Tests erstellen um Deine Code Units zu testen. Hier siehst Du noch mal den angelegten Unit Test:

<?php

namespace TestsUnit;

use TestsTestCase;
use IlluminateFoundationTestingRefreshDatabase;

class FooBarTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function testBasicTest()
    {
        $this->assertTrue(true);
    }
}

Solltest Du Deine eigene „setUp“ Funktion zum vorbereiten Deiner Tests erstellen, dann musst Du daran denken, die Funktion „parent:setUp“ mit aufzurufen, damit die Tests funktionieren.
Um nun Deinen frisch angelegten Unit Tests auszuführen, musst Du PHPUnit ausführen. Dazu gibt es mehrere Wege. Im Falle, dass Du PHPUnit nicht installiert hast, kannst Du die von Laravel mitgelieferte PHPUnit Datei „vendor/bin/phpunit.php“ über den folgenden Befehl in Deinem Laravel-Projektverzeichnis nutzen:

$ php ./vendor/bin/phpunit

Die zweite Möglichkeit ist, dass Du Dir PHPUnit in der benötigten Version 6.4.3 installierst und PHPUnit dann einfach über den folgenden Befehl in Deinem Laravel-Projektverzeichnis ausführst:

$ phpunit

Danach solltest Du ein ähnliches Ergebnis wie folgendes erhalten:

$ phpunit
PHPUnit 6.4.3 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 348 ms, Memory: 20.00MB

OK (2 tests, 2 assertions)

Komplettes Beispiel für Unit Tests in Laravel

Um das Ganze für Dich noch ein Stück greifbarer zu gestalten, werde ich Dir anhand von „Produkten“ zeigen, wie Du Deine Unit Tests aufbauen kannst. Du wirst in diesem Abschnitt ein Produkt Model und eine zugehörige Migration anlegen und einen passenden Controller dafür genierieren. Im Controller wirst Du passente CRUD (Create, Read, Update, Delete) Operationen, sowie die „Kaufen“ Funktion integrieren, die die Anzahl des gekauften Produktes reduziert.

Diese Funktionen wirst Du im Anschluss mit Unit Tests testen. Du wirst außerdem die „phpunit.xml“ um Environment Konfigurationen erweitern, damit nicht die „produktive“ Datenbank, sondern eine einfache SQLite Datenbank zum Testen verwendet wird. Wichtig ist, dass Du Dir SQLite3 und die PHP Extension zu SQLite3 installierst, sollte das nooch nicht der Fall sein. Unter Linux kannst Du dies mit den folgenden drei Befehlen tun:

$ sudo apt-get install sqlite3

Hier musst Du Deine installierte PHP Version kennen:

$ sudo apt-get install php7.0-sqlite3

Zum Schluss musst Du noch kurz den Apache Server neuladen:

$ sudo service apache2 reload

Du startest nun mit den Einträgen in der „phpunit.xml“. Ergänze die nachfolgenden Einträge in den „php“-Knoten:

<env name=“DB_CONNECTION“ value=“sqlite“ />
<env name=“DB_DATABASE“ value=“:memory:“ />

Als nächstes generierst Du mit Hilfe von Artisan das Produkt Model und die zugehörige Datenbank Migrationsdatei. Der Konsolenbefehl dazu lautet wie folgt:

$ php artisan make:model Product -m

Durch den Parameter „-m“ wird die Migration angelegt. Das Produkt wird sehr einfach aufgebaut sein, um die Komplexität in diesem Artikel gering zu halten. Du ergänzt gleich in der Migration die Spalten: Titel (string), Preis (decimal) und Anzahl (integer). Die erstellte Migration findest Du im Verzeichnis „database/migrations/“ und sie sollte einen ähnlichen Dateinamen wie folgenden tragen „2017_10_31_111518_create_products_table.php“. Mit den Spalten sollte Deine Migrationsdatei wie folgt aussehen:

<?php

use IlluminateSupportFacadesSchema;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateDatabaseMigrationsMigration;

class CreateProductsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->string('title');
            $table->decimal('price', 6, 2);
            $table->integer('stock');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('products');
    }
}

Nach dem Du die Migrationsdatei befüllt hast, solltest Du nun die „fillable“ Eigenschaften im Model hinterlegen. Dazu öffnest Du das Model „Product.php“ im Verzeichnis „app“ und fügst folgende Variable ein:

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Product extends Model
{
    protected $fillable = ['title', 'price', 'stock'];
}

In den Unit Testings musst Du später die Produkte „mocken“ oder „faken“. Dafür bringt Laravel eine „Faker“ Library mit. Diese Library benötigt eine „Factory“ zu dem Model Produkt, damit sie die richtigen Inhalte anlegen kann. Eine Factory für das Produkt legst Du über folgen Befehl an:

$ php artisan make:factory ProductFactory --model=Product

Artisan hat nun für Dich die Factory „ProductFactory.php“ unter „database/factories“ erstellt. Diese öffnest Du nun und fügst folgende „return“ Anweisung hinzu:

<?php

use FakerGenerator as Faker;

/* @var IlluminateDatabaseEloquentFactory $factory */

$factory->define(AppProduct::class, function (Faker $faker) {
    return [
        'title' => $faker->text(20),
        'price' => $faker->randomFloat(2),
        'stock' => $faker->randomNumber()
    ];
});

Jetzt bist Du soweit, dass Du den Unit Test anlegen kannst. Wie im ersten Teil dieses Beitrages, hilft Dir nun wieder Artisan:

$ php artisan make:test ProductTest --unit

Im Unit Test „ProductTest.php“ fügst Du nun die beiden Namespaces „AppProduct“ und „IlluminateFoundationTestingDatabaseMigrations“ hinzu und ergänzt zu Beginn der Klasse „use DatabaseMigrations;“ um die Datenbank Migrationen durchzuführen. Dadurch kannst Du nun in der SQLite Datenbank die in Deinem Hauptspeicher läuft Datenbankoperationen durchführen und so tun, als würdest Du auf der produktiven Datenbank arbeiten.

Danach legst Du den Unit Test „test_product_can_be_created“ als Funktion an und „fakest“ mit der zuvor erstellten Factory ein Produkt, dass direkt im Anschluss per „create“ in der SQLite Datenbank gespeichert wird.

Zu guter Letzt prüfst Du per „assertDatabaseHas“, ob das erstellte Produkt auch in der Datenbank gespeichert wurde.
Deine Unit Testing Sammlung sollte nun wie folgt aussehen:

<?php

namespace TestsUnit;

use TestsTestCase;
use IlluminateFoundationTestingRefreshDatabase;
use AppProduct;
use IlluminateFoundationTestingDatabaseMigrations;

class ProductTest extends TestCase
{
    use DatabaseMigrations;

    /** @test */
    public function test_product_can_be_created()
    {
        $product = factory(Product::class)->create();

        $this->assertDatabaseHas('products', [
            'id'    => 1,
            'title' => $product->title,
            'price' => $product->price,
            'stock' => $product->stock
        ]);
    }
}

Den Unit Test führt Du nun mit $ phpunit aus, wie ich es Dir zu Begin des Artikels gezeigt habe.
Sollten hier nun Fehler auftreten, wie bspw. „IlluminateDatabaseQueryException: could not find driver (SQL: PRAGMA writable_schema = 1;)“, dann hast Du vermutlich vergessen SQLite3 und/oder die PHP Extension zu SQLite3 zu installieren. Weiter oben findest Du drei Befehle zur installation unter Linux. Weitere vorkompilierte Binaries findest Du unter „SQLite - Download“.

Um nun noch den Fall zu testen, dass auch Produkte gekauft werden können, legst Du den passenden Produkt Controller über Artisan an:

$ php artisan make:controller ProductController

Zuerst fügst Du im im Controller wieder den Namespace „AppProduct“ hinzu und im Anschluss erstellst Du die Funktion „buy_product“ mit den Parametern „id“ und „quantity“:

<?php

namespace AppHttpControllers;

use IlluminateHttpRequest;
use AppProduct;

class ProductController extends Controller
{
    public function buy_product($id, $quantity) {
        $product = Product::findOrFail($id);

        if($quantity <= 0) {
            return response()->json( [
                'error'     => true,
                'message'   => 'Es muss mindestens ein Produkt bestellt werden.'
            ], 403 );
        } else if ($quantity > $product->stock) {
            return response()->json( [
                'error'     => true,
                'message' => 'Es sind leider nicht genügend Produkte auf Lager um diese Bestellung durchzuführen.'
            ], 403 );
        }

        $product->stock -= $quantity;
        $product->save();

        return response()->json( [
            'error'     => false,
            'message' => 'Produkte erfolgreich gekauft.'
        ], 200 );
    }
}

Damit Du die Funktion „buy_product“ aufrufen kannst, musst Du die Route konfigurieren. Öffne dazu die Datei „web.php“ im Verzeichnis „routes“. Dort ergänzt Du folgende Route:

Route::get('/products/{id}/buy/{quantity}', 'ProductController@buy_product');

Integriert wird hier eine Route die mit „/products/“ beginnt, danach muss eine Produkt ID übergeben werden. Im Anschluss folgt die Route „/buy/“ und die Anzahl der Produkte die gekauft werden sollen. Wird diese Route über HTTP Get aufgerufen wird die Funktion „buy_product“ im ProductController ausgeführt und die in der URL übergebenen Parameter an die Funktion übergeben.

Jetzt kannst Du für die folgenden Scenarien Test Funktionen anlegen, um den kompletten Kaufprozess und alle Möglichen Code Pfade zu Prüfen:

  • Das Produkt kann gekauft werden und der Warenbestand verringert sich
  • Das Produkt in der URL existiert nicht
  • Bei einem leeren Warenbestand kann das Produkt nicht gekauft werden
  • Es wurde keine Anzahl in der URL übergeben, obwohl mindestens ein Produkt gekauft werden muss

Diese drei Scenarien könnten wie folgt getestet werden:

/**
 * @test
 * Das Produkt kann gekauft werden und der Warenbestand verringert sich
 */
public function test_product_can_be_bought()
{
    $product = factory(Product::class)->create(['stock' => 16]);

    $response = $this->get('/products/1/buy/15');
    $response->assertStatus(200);

    $result = Product::find(1);
    $this->assertEquals($result->stock, 1);
}

/**
 * @test
 * Das Produkt in der URL existiert nicht
 */
public function test_not_existing_product_cant_be_bought()
{
    $product = factory(Product::class)->create();

    $response = $this->get('/products/42/buy/15');
    $response->assertStatus(404);
}

/**
 * @test
 * Bei einem leeren Warenbestand kann das Produkt nicht gekauft werden
 */
public function test_product_with_empty_stock_cant_be_bought()
{
    $product = factory(Product::class)->create(['stock' => 0]);

    $response = $this->get('/products/1/buy/1');
    $response->assertStatus(403);
}

/**
 * @test
 * Es wurde keine Anzahl in der URL übergeben, obwohl mindestens ein Produkt gekauft werden muss
 */
public function test_product_cant_be_bought_without_quantity()
{
    $product = factory(Product::class)->create(['stock' => 15]);

    $response = $this->get('/products/1/buy/0');
    $response->assertStatus(403);
}

Nun hast Du über Unit Tests das Produkt Model und den Produkt Controller erfolgreich getestet und alle Code Pfade im Controller wurden mit den Tests „abgelaufen“. Als nächstes zeige ich Dir, wie Du einen Code Coverage Report zu Deinen PHPUnit Unit Tests im Laravel Projekt automatisch erstellen lassen kannst.

PHPUnit Code Coverage Report für Unit Testing in Laravel

Die Gründe Code Coverage Reports zu erzeugen sind vielfältig. Sie können für sich betrachtet werden um manuell zu prüfen, wie gut die Tesstabdeckung des eigenen Projektes ist. Ein weiterer Grund könnte die Integration in einen Build-Prozess darstellen.
PHPUnit liefert von Haus aus die Möglichkeit, Code Coverage Reports zu erzeugen. Das wird, wie alles andere auch, in der „phpunit.xml“ konfiguriert. Füge dazu am Ende der „phpunit.xml“ einfach folgenden Knoten an:

<logging>
   <log type="coverage-html" target="./report_code-coverage" charset="UTF-8"
        yui="true" highlight="true"
        lowUpperBound="50" highLowerBound="80" />
</logging>

Durch den „log“ Eintrag im „logging“ Knoten wird nach Abschluss der Tests eine HTML Struktur im Verzeichnis „/report_code-coverage“ erzeugt. Die Grenzen für schlecht und gut getesten Code lassen sich mit „lowUpperBound“ und „highLowerBound“ festlegen. Eine mehr Infos findest Du in der offiziellen Dokumentation von PHPUnit. Informationen zum Logging findest Du im Unterpunkt „Logging“ unter „PHPUnit - Configuration
Das Ergebnis Deines Produkt Controllers nach dem Ausführen der Tests sieht folgendermaßen aus:

Schritt eins im Code Coverage Report: Auswahl des Verzeichnis http

Schritt zwei im Code Coverage Report: Auswahl der Datei ProductController.php

Schritt drei im Code Coverage Report: Analyse der Datei ProductController.php
Du hast nun die grundlegenden Werkzeuge für das Unit Testing in Laravel 5.5 an der Hand. Du hast gelernt, wie Du Deine „phpunit.xml“ erweitern kannst, wie Du eine Testdatenbank mit SQLite3 einrichtest und wie Du Tests für Models und Controller erstellst. Zum Schluss hast Du noch die automatische Generierung von Code Coverage Reports aktiviert.

Mögliche Fehler beim Ausführen der PHPUnit Unit Tests in Laravel

Anbei folgt eine Liste möglicher Fehler die beim Unit Testing mit PHPUnit unter Laravel 5.5 auftreten können. Du kannst dabei helfen, diese Liste zu erweitern in dem Du von Deinen Fehlern berichtest.

Fehler 1 - Expected status code 200 but received 404

Beim Ausführen Deiner HTTP – Unit Tests (get / call) tritt folgender Fehler auf:

There was 1 failure:
1) TestsFeatureExampleTest::testBasicTest
Expected status code 200 but received 404.
Failed asserting that false is true.

Mögliche Lösung:

Du hast für Dein Laravel-Projekt einen „virtual host“ / „vhost“ Eintrag angelegt, damit Du Deine Website über eine „richte“ URL erreichen kannst: „http://mein-projekt.dev“.
In diesem Fall musst Du in der „phpunit.xml“ den folgenden Environment Eintrag ergänzen:

<env name="APP_URL" value="http://mein-projekt.dev"/>

 

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.


Paypal in Laravel

Paypal in Laravel 5.5 integrieren – so funktioniert's

Viele Internetseiten und Webshops bieten heutzutage Paypal als Bezahlungs-Gateway an, denn Paypal zählt zu den schnellsten und sichersten Bezahlsystemen weltweit. In diesem Artikel zeige ich Dir, wie Du die von Paypal auf Github bereitgestellte PHP SDK in Dein Laravel – Projekt integrieren kannst. Außerdem werden wir die Laravel Authentication aktivieren und über eine Datenbank Migration die Spalten „paypal_agreement_status“ und „paypal_agreement_id“ zur Benutzer Tabelle hinzufügen. Dadurch kannst Du Paypal Bezahl-Abonnements einem Benutzer zuordnen, den Status des Abonnements im Auge behalten und diesen gegebenenfalls aktuallisieren.

Die Paypal PHP SDK

Paypal bietet unter seinem Github Account eine eigene PHP SDK an (Github - Paypal php sdk). Durch die SDK kannst Du spielend leicht die Paypal Rest-API ansteuern, um bspw.:

  • Einzelne Bezahlungen zu integrieren
  • Den Status eines Bezahlvorgangs per Token abzurufen
  • Bezahl-Abonnements zu planen (Billing plans)
  • Abonnement-Verträge (agreements) nach Vorlage geplanter Abonnements anzulegen
  • Den Status eines Abonnement-Vertrages abzurufen
  • Einen Abonnement-Vertrag zu pausieren, oder zu re-aktivieren

Im Zuge dieses Artikels werde Ich Dir Schritt für Schritt zeigen wie Du die SDK in Dein Projekt einbindest, wie Du „billing plans“ anlegst und wie Du diese verwenden kannst um Abonnements zu erstellen. Zum Schluss zeige ich Dir noch, wie Du mit Hilfe der „agreement_id“ eines laufenden Abonnements dessen Status abfragen kannst (bspw.: aktiv, pausiert, beendet). Eine Dokumentation über die SDK von Paypal findest Du unter: Github - Paypal php sdk

Integration der Paypal PHP SDK – Paypal in Laravel integrieren

Schritt 1 - Einrichtung des Laravel Projektes

Dadurch, dass die PHP SDK als Repository auf Github bereitgestellt wird und das Repository über eine „composer.json“ verfügt, kannst Du es einfach als Abhängigkeit in die „composer.json“ Deiner Laravel Anwendung integrieren. Aber zunächst solltest Du mit Hilfe von Composer Dein Laravel Projekt über die Konsole/das Terminal anlegen, sofern das noch nicht geschehen ist. Füge bei „myApplication“ den Name Deiner Laravel Anwendung ein:

$ composer create-project --prefer-dist laravel/laravel myApplication

Solltest Du Composer noch nicht kennen, dann kannst Du unter Get Composer mehr über den PHP Paket-Manager erfahren. Dort findest Du eine Installationsanleitung, einen Artikel der Dich in die Benutzung von Composer einführt und auch eine Dokumentation.
Nachdem Du nun ein Projekt besitzt, kannst Du die benötigten Github Repositories mit Hilfe von Composer in Dein Projekt integrieren. Zunächst integrierst Du das Paket „paypal/rest-api-sdk-php“ per Konsolenbefehl:

$ composer require paypal/rest-api-sdk-php

Um auf einem einfachen Weg Http Anfragen von Deiner Laravel Anwendung zu versenden, kannst Du das Github Repository „Guzzle“ verwenden. Dadurch wird das Ansprechen der Rest-API erleichtert. Solltest Du „Guzzle“ also noch nicht verwenden, so kannst Du es mit Hilfe von Composer über die Konsole integrieren:

$ composer require guzzlehttp/guzzle

Schritt 2 – Vorbereitung der Datenbankanbindung

Nun bereitest Du die Anbindung von Laravel an die Datenbank vor. Zu aller erst legst Du auf Deinem Datenbankserver eine Datenbank für das Projekt an. Danach hinterlegst Du die DB Informationen in der „.env“ Datei. Wie die Datenbankanbindung im Detail abläuft, erfährst Du unter Laravel 5.5 - Database.
Wärend späterer Datenbank-Migrationen ist es auch nützlich eine Standartlänge für Datenbank „strings“ festzulegen. Dadurch ersparst Du Dir unnötige Fehler und Schreibarbeit. Um die Standartlänge festzulegen kannst Du unter „myApplication/app/Providers“ den Provider „AppServiceProvider“ öffnen und fügst dort den Namespace „IlluminateSupportFacadesSchema“ hinzu. Danach musst Du in der Funktion „boot“ nurnoch folgenden Funktionsaufruf intergerieren:

public function boot()
{
     Schema::defaultStringLength(150);
}

Schritt 3 – Laravel User Authentication aktivieren

Um die von Laravel mitgelieferte User Authentication nutzen zu können, musst Du sie vorher „aktivieren“. Dabei hilft Dir das Laravel-Tool Artisan. Artisan ist eine PHP Anwendung, die Du parametrisiert in der Konsole ausführen kannst und die für Dich diverse Aufgaben rund um Deine Laravel Anwendung ausführt. Mit dem folgenden Befehl erstellt Artisan alle benötigten Komponenten wie Model, Views, Controller und Routen:

$ php artisan make:auth

Nun legst Du eine neue Migrationsdatei an, damit Deine Benutzer die zu Beginn erwähnten Spalten „paypal_agreement_status“ und „paypal_agreement_id“ erhalten. Eine Migration legst Du ebenfalls über Artisan an, wobei Du nach dem „make“ Befehl noch einen aussagekräftigen Namen für die Migrationsdatei angeben solltest:

$ php artisan make:migration add_paypal_agreement_to_user

Nun öffnest Du die gerade erstellte Migration. Diese findest Du unter „myApplication/database/migrations“. In dieser Datei gibt es nun zwei leere Methoden, „up“ und „down“. Füge nun folgenden Code in die „up“ – Funktion ein:

public function up() {
	Schema::table('users', function($table) {
		$table->integer('paypal_agreement_id');
		$table->string('paypal_agreement_status');
	}
}

Dem Status („paypal_agreement_status“) wird später der enum Wert der „state“ Eigenschaft vom Rest API Aufruf zugewiesen. Dazu verwendest Du später die Rest API „v1/payments/billing-agreements/agreement_id“ (Paypal Developer Docs - Billing agreements).
Um einen sauberen Rollback durchführen zu können muss noch die „down“ - Funktion implementiert werden. In dieser tauscht Du die Datentypen einfach gegen die Funktion „dropColumn“:

public function down() {
	Schema::table('users', function($table) {
		$table->dropColumn('paypal_agreement_id');
		$table->dropColumn('paypal_agreement_status');
	}
}

Damit die nun erstellte Migration und die vorherigen Migrationen in die Datenbank eingespielt werden, musst Du einen Artisan Konsolenbefehl ausführen:

$ php artisan migrate

Die Migrationen werden dadurch in die Datenbank überspielt und Du kannst die von Laravel mitgelieferte User Authentication nutzen.

Schritt 4 – Erstellen der Paypal Config Datei

Als nächstes erstellst Du eine Konfigurationsdatei in der alle Einstellungen und Schlüssel von Paypal hinterlegt werden. Die Datei erstellst Du unter „myApplication/config“ mit dem Dateinamen „paypal.php“:

<?php
return array(
    /**
     * Sandbox und Live credentials
     */
    'credentials' => array(
        'sandbox' => array(
            'client_id' => env('PAYPAL_SANDBOX_CLIENT_ID', ''),
            'secret' => env('PAYPAL_SANDBOX_SECRET', '')
        ),
        'live' => array(
            'client_id' => env('PAYPAL_LIVE_CLIENT_ID', ''),
            'secret' => env('PAYPAL_LIVE_SECRET', '')
        ),
    ),
    /**
     * SDK Konfiguration
     */
    'settings' => array(
        /**
         * Payment Mode
         *
         * Optionen: 'sandbox' oder 'live'
         */
        'mode' => env('PAYPAL_MODE', 'sandbox'),

        // Angabe in Sekunden
        'http.ConnectionTimeOut' => 3000,
        'log.LogEnabled' => true,
        'log.FileName' => storage_path() . '/logs/paypal.log',

        /**
         * Log Level
         *
         * Optionen: 'DEBUG', 'INFO', 'WARN' oder 'ERROR'
         */
        'log.LogLevel' => 'DEBUG'
    ),
);

In der Paypal Konfiguration werden die Schlüssel „client_id“ und „secret“ für den Sandbox und Live Modus mit der PHP Funktion „env()“ geladen. Diese müssen nun noch in der „.env“ Datei hinterlegt werden, die Werte folgen im Anschluss:

PAYPAL_MODE=

PAYPAL_SANDBOX_CLIENT_ID=
PAYPAL_SANDBOX_SECRET=

PAYPAL_LIVE_CLIENT_ID=
PAYPAL_LIVE_SECRET=

Schritt 5 – Registrieren der Anwendung in Paypal

Nun musst Du Deine Anwendung bei Paypal registrieren, um die „client_id“ sowie den „secret“ Code für den jeweiligen Modus zu erhalten. Dazu besuchst Du: Paypal Developer und meldest Dich mit Deinen Benutzerdaten unter „Log into Dashboard“ an.

 

Paypal Developer Seite

 

Nach dem Login kommst Du auf Dein Paypal Development Dashboard. Wenn Du auf diesem runterscrollst bis zur Sektion „REST API apps“, dann kannst Du über den Knopf „Create App“ eine neue Rest API Anwendung zu Deinem Account hinzufügen.

 

Paypal Developer Dashboard Sektion: REST API apps

 

Wenn Du auf „Create App“ geklickt hast, öffnet sich die Maske zur Erstellung einer neuen Anwendung. In dieser trägst Du den Namen Deiner Anwendung ein und wählst den „facilitator“ Sandbox Developer Account aus. Dieser sollte im Normalfall automatisch erstellt werden. Wenn die erste Erstellung des Accounts noch nicht geklappt hat, schlägt die Erstellung der Anwendung ebenfalls fehl. Versuche es einfach nach einer Weile erneut.

 

Paypal Developer: Neue App hinzufügen

 

Nachdem die Anwendung erfolgreich erstellt wurde, gelangst Du auf die Detailansicht Deiner Anwendung. Auf dieser findest Du die benötigten Ids und secret Codes. Oberhalb der Box „Sandbox API Credentials“ findest Du zwei Knöpfe um zwischen dem Sandbox und dem Live Modus zu wechseln.

 

Paypal Developer: Erstellte App mit sichtbarer Client-ID

 

Wenn Du auf „Show“ unter „Secret“ klickst, erweitert sich das Panel und Du kannst den jeweiligen Secret Code sehen.

 

Paypal Developer: Erstellte App mit sichtbarer Client-ID und sichtbarem Secret Code

 

Nun kopierst Du die Schlüssel und fügst sie in die „.env“ Datei ein:

PAYPAL_MODE=sandbox

PAYPAL_SANDBOX_CLIENT_ID=hier-steht-normalerweise-eine-lange-client-id
PAYPAL_SANDBOX_SECRET=hier-steht-normalerweise-ein-langer-secret-code

PAYPAL_LIVE_CLIENT_ID=hier-steht-normalerweise-eine-lange-client-id
PAYPAL_LIVE_SECRET=hier-steht-normalerweise-ein-langer-secret-code

Schritt 6 – Erstellung des Paypal Controllers

Die „.env“ Datei ist gefüllt und Du kannst jetzt den Laravel Controller zur Verwaltung der Paypal PHP SDK erstellen. Dabei hilft Dir wieder Artisan:

$ php artisan make:controller PaypalController

Artisan hat nun für Dich einen leeren Controller mit dem Namen „PaypalController.php“ im Verzeichnis „myApplication/app/Http/Controllers“ angelegt. In diesen fügst Du nun folgende Namespaces hinzu:

use PayPalApiChargeModel;
use PayPalApiCurrency;
use PayPalApiMerchantPreferences;
use PayPalApiPaymentDefinition;
use PayPalApiPlan;
use PayPalApiPatch;
use PayPalApiPatchRequest;
use PayPalCommonPayPalModel;
use PayPalRestApiContext;
use PayPalAuthOAuthTokenCredential;

Als nächstes benötigst Du noch einige Felder um die Paypal Daten auf der Konfiguration zwischenzuspeichern. Diese integriest Du am besten ganz oben in Deinem Controller:

private $apiContext;
private $mode;
private $client_id;
private $secret;

Diese Felder füllst Du nun über den Konstruktor. Hier kommt auch die zuvor erstellte Konfiguration zum Einsatz:

public function __construct() {
    // Zunächst wird geprüft, in welchem Modus sich die App befindet
    if(config('paypal.settings.mode') == 'live'){
        $this->client_id = config('paypal.credentials.live.client_id');
        $this->secret = config('paypal.credentials.live.secret');
    } else {
        $this->client_id = config('paypal.credentials.sandbox.client_id');
        $this->secret = config('paypal.credentials.sandbox.secret');
    }

    // Nun legst Du den Paypal API Context fest
    $oAuthToken = new OAuthTokenCredential($this->client_id, $this->secret)
    $this->apiContext = new ApiContext();
    $this->apiContext->setConfig(config('paypal.settings'));
}

Durch das Setzen des Kontextes im Konstruktor können wir in den Funktionen des Controllers immer mit dem korrekten Kontext arbeiten. Wichtig ist, dass wir alle API Zugriffe in einem Try-Catch Block unterbringen, da gegebenenfalls ein Zugriffsfehler („PayPalConnectionException“) auftreten kann.

Schritt 7 – Erstellung einer Paypal Abonnement Vorlage

Nun kannst Du mit Hilfe Deiner Paypal Konfiguration und der Schlüssel eine Vorlage für ein Bezahl-Abonnement erstellen und aktivieren. Dies ist notwendig, damit die Benutzer später Abonnements abschließen zu können. Paypal benötigt für ein „Billing agreement“ zuerst einen „Billing plan“ als Vorlage.
In dem folgenden Beispiel benötigst Du die Funktion zum Anlegen der Vorlage nur ein mal, danach kann sie theoretisch entfernt werden. Du kannst natürlich einen komplexeren Workflow entwickeln, in dem administrative Nutzer Vorlagen für Abonnements mit Hilfe dieser Methode anlegen können. In diesem Fall müsstest Du die generierten Plan Ids in einer Datenbank speichern und nicht in der „.env“ Datei.
Erstelle nun unterhalb des Konstruktors die Funktion „create_billing_plan“:

public function create_billing_plan() {
        // Erstelle einen neuen Plan
        $plan = new Plan();
        $plan->setName('myApplication - Monatsabonnement')
          ->setDescription('Monatlich abgerechnetes Abonnement der App myApplication')
          ->setType('infinite');

        // Nun definierst Du die Bezahlparameter
        $paymentDefinition = new PaymentDefinition();
        $paymentDefinition->setName('Regelmäßige Bezahlung')
          ->setType('REGULAR')
          ->setFrequency('Month')
          ->setFrequencyInterval('1')
          ->setCycles('0')
          ->setAmount(new Currency(array('value' => 15, 'currency' => 'EUR')));

        // An dieser Stelle definierst Du die Anbieter Einstellungen
        $merchantPreferences = new MerchantPreferences();
        $merchantPreferences->setReturnUrl('https://myapplication.dev/subscribe/paypal/return')
          ->setCancelUrl('https://myapplication.dev/subscribe/paypal/return')
          ->setAutoBillAmount('yes')
          ->setInitialFailAmountAction('CONTINUE')
          ->setMaxFailAttempts('0');

        $plan->setPaymentDefinitions(array($paymentDefinition));
        $plan->setMerchantPreferences($merchantPreferences);

        // Jetzt ist alles vorbereitet, der Plan kann also bei Paypal erstellt werden
        try {
            $createdPlan = $plan->create($this->apiContext);

            try {
                $patch = new Patch();
                $value = new PayPalModel('{"state":"ACTIVE"}');
                $patch->setOp('replace')
                  ->setPath('/')
                  ->setValue($value);
                $patchRequest = new PatchRequest();
                $patchRequest->addPatch($patch);
                $createdPlan->update($patchRequest, $this->apiContext);
                $plan = Plan::get($createdPlan->getId(), $this->apiContext);

                // Zum Schluss wird noch die ID ausgegeben, wenn alles geklappt hat
                echo 'Vorlagen ID für das Abonnement: ' . $plan->getId();
            } catch (PayPalExceptionPayPalConnectionException $ex) {
                echo $ex->getCode();
                echo $ex->getData();
                die($ex);
            } catch (Exception $ex) {
                die($ex);
            }
        } catch (PayPalExceptionPayPalConnectionException $ex) {
            echo $ex->getCode();
            echo $ex->getData();
            die($ex);
        } catch (Exception $ex) {
            die($ex);
        }
    }
}

In dieser Funktion ist nun einiges Integriert. Du kannst die Funktion selbstverständlich noch refaktorisieren, wenn Du sie in einen komplexeren Workflow einbetten willst.
Zu Beginn der Funktion legst Du einen neuen Plan an und initialisierst die benötigten Eigenschaften wie die Bezahlparameter und den Anbieter.
Deine definierte Vorlage hat folgenden Aufbau:

  • Name: „myApplication – Monatsabonnement“
  • Beschreibung: „Monatlich abgerechnetes Abonnement der App myApplication“
  • Typ: „infinite“
  • Bezahlparameter:
    • Name: „Regelmäßige Bezahlung“
    • Type: „REGULAR“
    • Abbuchung: „Month“
    • Abbuchungsintervall: „1“
    • Anzahl der Zahlungen: „0“ (durch Type: „infinite“)
    • Betrag: „15 Euro“
  • Anbieter Einstellungen:
    • URL zur Zahlungsbestätigung: „https://myapplication.dev/subscribe/paypal/return“
    • URL zum Zahlungsabbruch: „https://myapplication.dev/subscribe/paypal/return“
    • Sollen unbezahlte Beträge im nächsten Zyklus mitabgebucht werden: „yes“
    • Aktion beim Fehlschlagen der ersten Abbuchung: „CONTINUE“ (Der Betrag wird bei der nächsten Abbuchung addiert)
    • Maximale Fehler beim Abbuchen: „0“ (unbegrenzt)

Wichtig ist, dass Du die Domain im Beispiel gegen Deine Domain tauscht. Weiterhin kann es sein, dass es Probleme mit Links geben kann, in denen ein Port angegeben ist (bspw.: „http://localhost:8080/subscribe/paypal/return“). Am besten Du legst einen V-Host über Deinen lokalen Webserver an (bspw.: „http://local-app.dev/“), dann hast Du damit keine Probleme.
Nun öffnest Du die Routen-Datei „web.php“ unter „myApplication/routes“ und trägst folgende Route zu der „create_billing_plan“ Funktion ein:

Route::get('/create-plan', 'PaypalController@create_billing_plan');

Wenn Du nun im Browser die Url „http://myapplication.dev/create-plan“ aufrufst, wird die „billing plan id“ ausgegeben. Diese Id kannst du nun in der „.env“ Datei hinterlegen:

PAYPAL_SANDBOX_PLAN_ID=hier-steht-normalerweise-eine-plan-id
PAYPAL_LIVE_PLAN_ID=hier-steht-normalerweise-eine-plan-id

Wenn Du mit Deiner Website in den Live Modus gehen willst, musst Du die Url noch mal im Live Modus aufrufen, damit die Vorlage auch für diesen Angelegt wird.Wichtig ist, dass Du daran denkst, die Route in der Routen Datei „web.php“ auszukommentieren, wenn Du sie nicht mehr benötigst.

// Route::get('/create-plan', 'PaypalController@create_billing_plan');

Schritt 8 – Eintragung der Paypal Routen

Trage nun die produktiven Routen für Deinen Paypal Controller in die „web.php“ Routen Datei ein:

Route::get('/subscribe/paypal', 'PaypalController@paypal_redirect')->name('paypal.redirect');
Route::get('/subscribe/paypal/return', 'PaypalController@paypal_return')->name('paypal.return');

Schritt 9 – Abonnements im Paypal Controller anlegen

Nun fügst Du noch die beiden vorher angelegten Routen als Funktionen zu Deinem Paypal Controller hinzu, davor musst Du aber noch die „billing plan“ Id aus der env Datei laden. Dies passiert im Konstruktor:

private $apiContext;
private $mode;
private $client_id;
private $secret;
private $plan_id;

public function __construct() {
    // Zunächst wird geprüft, in welchem Modus sich die App befindet
    if(config('paypal.settings.mode') == 'live'){
        $this->client_id = config('paypal.credentials.live.client_id');
        $this->secret = config('paypal.credentials.live.secret');
        $this->plan_id = env('PAYPAL_LIVE_PLAN_ID', '');
    } else {
        $this->client_id = config('paypal.credentials.sandbox.client_id');
        $this->secret = config('paypal.credentials.sandbox.secret');
        $this->plan_id = env('PAYPAL_SANDBOX_PLAN_ID', '');
    }

    // Nun legst Du den Paypal API Context fest
    $oAuthToken = new OAuthTokenCredential($this->client_id, $this->secret)
    $this->apiContext = new ApiContext();
    $this->apiContext->setConfig(config('paypal.settings'));
}

Selbstverständlich kannst Du auch noch ein Key-Value Paar in Deine Paypal Konfiguration aufnehmen, um bspw. mehrere vordefinierte Pläne bereitzustellen. Nun erstellst Du die erste Methode, in der ein Vertrag mit der Paypal PHP SDK angelegt wird. Danach wird der Benutzer nach Paypal weitergeleitet:

<?php
public function paypal_redirect(){
    // Anlegen eines neuen Abonnements
    $agreement = new Agreement();
    $agreement->setName('myApplication - Abonnement')
      ->setDescription('Basisvertrag')
      ->setStartDate(CarbonCarbon::now()->addMinutes(5)->toIso8601String());

    // Nun wird dem Vertrag die zuvor generierte „billing plan“ ID zugewiesen
    $plan = new Plan();
    $plan->setId($this->plan_id);
    $agreement->setPlan($plan);

    // Kundendetails – Hier reicht die Bezahlmethode. Kann aber erweitert werden
    $payer = new Payer();
    $payer->setPaymentMethod('paypal');
    $agreement->setPayer($payer);

    try {
      // Nun kannst Du den Vertrag über die PHP SDK vorbereiten
      $agreement = $agreement->create($this->apiContext);

      // Nun wird noch die Bestätigungs-Url von Paypal ermittelt um den nutzer weiterzuleiten
      $approvalUrl = $agreement->getApprovalLink();

      return redirect($approvalUrl);
    } catch (PayPalExceptionPayPalConnectionException $ex) {
      echo $ex->getCode();
      echo $ex->getData();
      die($ex);
    } catch (Exception $ex) {
      die($ex);
    }

}

Also nächstes brauchst Du noch die Funktion auf die Paypal den Benutzer zurückleitet. An dieser Stelle bekommst Du von Paypal auch einen Token übergeben mit dessen Hilfe Du bei der Vertragserstellung erkennen kannst, ob der Benutzer mit dem Vertrag einverstanden ist:

public function paypal_return(Request $request){
    $token = $request->token;
    $agreement = new PayPalApiAgreement();

    try {
        // Jetzt kannst Du versuchen mit dem Paypal Token den Vertrag anzulegen
        $result = $agreement->execute($token, $this->apiContext);
        $user = Auth::user();
        $user->role = 'Abonnent';
        $user->paypal = 1;

        if(isset($result->id)){
            $user->paypal_agreement_id = $result->id;
        }

        $user->save();
        echo 'Ein neuer Abonnent wurde eingetragen!';

    } catch (PayPalExceptionPayPalConnectionException $ex) {
        echo 'Entweder hast Du die Bezahlung abgebrochen, oder Deine Sitzung ist abgelaufen.';
    }
}

Ist alles gut gelaufen wird Dir eine „agreement Id“ zurückgegeben und diese kannst Du im Benutzer speichern. Deine Benutzer können über die Url „http://myapplication.dev/subscribe/paypal“ nun ein Abonnement abschließen. Wie Du die Url schlussendlich integrierst ist Dir überlassen. Hier folgt noch ein Beispiel für die Integration über eine einfachen Link:

<a href="{{ url('subscribe/paypal') }}" class=“paypal-btn“>Abonnement anlegen</a>

Schritt 10 – Abfragen des Status eines Abonnements

Im letzten Schritt wollte ich Dir noch zeigen, wie Du schnell den Status eines Abonnements abfragen kannst, um bspw. zu erkennen, ob dieses noch aktiv ist. Deine Nutzer können diese ja auch einstellen. Zunächst ergänzt Du Deine Routen um folgenden Eintrag:

Route::get('/agreement-status', 'PaypalController@paypal_agreement_status');

Im Paypal Controller solltest Du nun noch den folgenden Namespace hinzufügen:

use PaypalApiAgreement;

Den Paypal Controller erweiterst Du nun um folgende Funktion:

public function paypal_agreement_status(Request $request){
    try {
        $user = Auth::user();
        $agreement = Agreement::get($user->paypal_agreement_id, $this->apiContext);

        if(isset($agreement->id)){
            $user->paypal_agreement_status = $agreement->state;
        }

        $user->save();
        echo sprintf('Das Abonnement „%s“ hat aktuell den Status „%s“.', $agreement->id, $agreement->state);

    } catch (PayPalExceptionPayPalConnectionException $ex) {
        echo 'Beim Abrufen des Abonnement-Status kam es zum Fehler.';
        echo $ex->getCode();
        echo $ex->getData();
        die($ex);
    }
 }

Dies ist ebenfalls nur eine Beispielfunktion, die Dir zeigen soll, wie einfach Du den Status abrufen kannst. Willst Du weitere Funktionen von Paypal in Deiner Anwendung integrieren, so wirf doch einen Blick in die Dokumentation. Paypal liefert in Deiner PHP SDK auch viele nützliche Beispiele an denen Du Dich bei der integration orientieren kannst Github - Paypal php sdk - Beispiel.

 

In diesem Artikel hast Du gelernt, wie Du die PHP SDK von Paypal in Laravel 5.5 integrierst. Du hast Composer eingesetzt um Laravel aufzusetzen und die PHP SDK zu integrieren. Weiterhin hast Du Projektkomponenten wie z.B. Controller und Migrationen mit dem Tool Artisan angelegt. Dein Paypal Controller wurde mit der PHP SDK verknüpft und Du hast mit Hilfe eines "billing plan" eine Möglichkeit geschaffen, um Abonnements einzurichten und deren Status abzufragen.

Solltest du Fragen oder Anregungen zum Thema haben, hinterlasse einfach ein Kommentar.

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.