Hoe wij sleutelen aan een applicatie die draait op het bekende Zend Framework en al 10 jaar lang gebruikt wordt door onze partners.

De afgelopen jaren zijn er in de wereld van PHP-frameworks andere alternatieven op Zend Framework om de hoek komen kijken. Denk aan bijvoorbeeld Symfony of Laravel. Bij IMPRES werken wij graag met Laravel. Worden we vrolijk van! Zeer leesbare API en makkelijk te begrijpen concepten met oog voor DX (Developer Experience), een factor die we bij IMPRES steeds belangrijker vinden.

Als developer wil je je veilig voelen in een codebase. Veilig in de zin van: met zekerheid een inschatting kunnen maken wanneer de klant om een nieuwe feature vraagt. Je wilt twijfels aan jezelf voorkomen en geen bestaande systemen in gevaar brengen.

Bandenhotel meets Laravel

Om Bandenhotel door te kunnen blijven ontwikkelen willen we Laravel gaan gebruiken. Dit zouden we kunnen doen door de applicatie volledig te herschrijven. Wat veel tijd kost, en ook lastig is om in te schatten. Daarnaast zal er een spannend moment komen van overstappen op “Bandenhotel 2.0 (Laravel edition)”. Wanneer je dit één grote stap wilt doen, kan er veel misgaan. Dat willen we liever niet. Daarom doen we dit in stapjes, middels de Pacmanstrategie (lees ons vorig blog hierover)!

Hoe passen we dit toe in de Laravel/Zend Framework-mix? Beide frameworks zijn MVC-frameworks. Wat inhoudt dat ze allebei werken met een router. Een router is een functie die ervoor zorgt dat een url gemapped wordt naar een controller. Om Bandenhotel feature voor feature te kunnen omzetten zouden we er dus voor kunnen zorgen dat Laravel een route afvangt voordat Zend Framework dat doet. Bestaat een route niet in Laravel? Dan laten we het request afhandelen door Zend Framework.

Untitled Diagram 1

Klaar voor de start?

Allereerst hebben wij een nieuw Laravel-project gegenereerd. Vervolgens hebben we in de project root directory een mapje gemaakt die heet “legacy”. Hierin leeft de Zend applicatie.

Screenshot 2020 02 14 at 121857

Als je nu het project opent in de browser zie je keurig de bekende welcome.blade.php template van Laravel. We willen nu by default elk request doorsturen naar het Zend Framework. Dit hebben we gedaan met een wildcard route. Een route die elk request afvangt wat niet eerder gematcht is.

	Route::any('/{path?}', 'FallbackController@fallback')->where('path', '.*');

Deze route moet altijd onderaan de routefile weergegeven worden zodat deze pas als laatst gematcht wordt. Een wildcard route vangt namelijk alles af.

De wildcard route verwijst naar de FallbackController. Dit is de controller die de Zend_Application instantieert en simpelweg run() uitvoert.

	<?php

namespace App\Http\Controllers;

class FallbackController
{
    public function fallback(\Plano_Application $application)
    {
        ob_start();
        $application->run();
        return response(ob_get_clean());
    }
}

Zend_Application lijkt eigenlijk best wel op de Http\Kernel class van Laravel. Dit is eigenlijk het startpunt van de Zend waarbij het request wordt afgevangen en de correcte controller wordt aangeroepen. Zend Framework creëert zelf het response. Wij vangen dit af met de output buffer functies van PHP en geven netjes een response terug middels Laravel.

Op deze manier kunnen we de code verplaatsen en refactoren naar Laravel op een route-voor-route wijze. Dit zorgt ervoor dat we per feature kunnen kijken (of zelfs delen van een feature) of we dit al naar Laravel willen verplaatsen.

Natuurlijk zijn er ook een aantal beren op de weg. Hoe zorgen wij ervoor dat een gebruiker zowel in Laravel als in Zend Framework geauthenticeerd wordt? Hoe gaan we om met sessies? Hoe refereren we naar een route die niet in Laravel bestaat maar wel in Zend? Hoe schrijven we data weg in de database op een manier zodat Zend Framework er ook nog mee overweg kan? Dat ga ik je hieronder laten zien!

Authenticatie

In Zend Framework heb je de Zend_Auth class. Deze is verantwoordelijk voor het controleren of de huidige gebruiker is ingelogd. Een beetje vergelijkbaar met een Auth guard van Laravel alleen nét een andere interface. Geen probleem, dat knopen we gewoon aan elkaar!

Dit doen we door een ZendGuard class te introduceren. Deze implementeert de StatefulGuard interface van Laravel. Vervolgens implementeren methoden zoals check() en guest() door de Zend_Auth class aan te roepen en te controleren of Zend een geauthenticeerde gebruiker in zijn sessie heeft staan.

	<?php

class ZendGuard implements StatefulGuard
{
    private $adapter;

    private $user;

    public function __construct($adapter)
    {
        $this->adapter = $adapter;
    }

    /**
     * Determine if the current user is authenticated.
     *
     * @return bool
     */
    public function check()
    {
        return Zend_Session::sessionExists() && $this->adapter->hasIdentity();
    }

    /**
     * Get the currently authenticated user.
     *
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function user()
    {
        if (!isset($this->user)) {
            if ($this->isAdminGuard()) {
                return $this->user = Admin::find($this->adapter->getIdentity()->id);
            }

            if ($this->isCompanyGuard()) {
                return $this->user = CompanyUser::find($this->adapter->getIdentity()->id);
            }

            throw new \RuntimeException('Zend auth adapter not supported');
        }

        return $this->user;
    }

    public function isAdminGuard()
    {
        return get_class($this->adapter) === \Zend_Auth::class;
    }

    public function isCompanyGuard()
    {
        return get_class($this->adapter) === \Main_Company_Service_Auth::class;
    }
}

Waarom kijken we zelf niet in de sessie, vraag je je natuurlijk af. In dit geval is dit stukje sessie de verantwoordelijkheid van Zend_Auth. Je wilt per definitie niet een variabele benaderen die binnen de “verantwoordelijkheid” van een andere interface valt. Dit kan in de toekomst voor problemen zorgen, wanneer bijvoorbeeld de interface intern zijn naamgeving aanpast.

Sessies

Laravel maakt geen gebruik van de native PHP-sessie zoals Zend Framework dat doet. Om zo dicht mogelijk bij Laravel te blijven willen we sessies gebruiken zoals Laravel het aanbiedt. Helaas zorgt dit ervoor dat wij in Laravel niet bij de sessievariabelen kunnen van Zend Framework.

In mijn optiek een goede restrictie zodat een developer geforceerd wordt om tijdens het ‘pacmannen’ de Laravel/Zend Framework-combinatie correct van elkaar te decoupelen.

Helaas moeten we er dan ook voor waken dat de sessie niet zomaar gestart wordt door Zend. Alleen wanneer de route ook voor Zend bedoeld is. Anders krijgen we een `Headers already sent` exception wanneer PHP een sessie cookie probeert mee te sturen wanneer Laravel al een response klaar heeft staan.

Routes

In Laravel kun je elke route een naam geven. Dit is heel handig wanneer je bijvoorbeeld een knop wilt presenteren die naar een bepaalde route navigeert. Als de route wijzigt? Geen probleem! Want alle verwijzingen staan naar de route name.

Echter kunnen we natuurlijk ook wel eens verwijzen naar een route die nog niet is gemigreerd naar Laravel. Dit is waar onze `legacy.php` route file om de hoek komt kijken.

	<?php

// Index
Route::any('/admin/stock/return-call', 'FallbackController@fallback')->name('admin.stock.checkout.index');
Route::any('/stock/return-call', 'FallbackController@fallback')->name('stock.checkout.index');

// View
Route::any('/admin/stock/return-call/view/id/{id}', 'FallbackController@fallback')->name('admin.stock.checkout.view');
Route::any('/stock/return-call/view/id/{id}', 'FallbackController@fallback')->name('stock.checkout.view');

// Create
Route::any('/admin/stock/return-call/add', 'FallbackController@fallback')->name('admin.stock.checkout.create');
Route::any('/stock/return-call/add', 'FallbackController@fallback')->name('stock.checkout.create');

// Checkin id
Route::any('/admin/stock/return-call/add/id/{transaction_id}', 'FallbackController@fallback')->name('admin.stock.checkout.add.transaction_id');
Route::any('/stock/return-call/add/id/{transaction_id}', 'FallbackController@fallback')->name('stock.checkout.add.transaction_id');

Hierin definiëren wij alle routes die nog niet gemigreerd zijn maar waar wij wel een referentie naar toe hebben vanuit code of een Blade view. De route verwijst vervolgens simpelweg naar de FallbackController.

Andersom bestaat dit helaas niet. Dit hebben wij dan ook simpelweg opgelost door de exacte routes aan te houden zoals die nu in Zend Framework zijn gedefinieerd. Dit is soms wat verwarrend omdat de route werking van Zend Framework anders werkt dan Laravel.

In Zend Framework begint een route altijd met de controller naam, vervolgens de naam van de actie gevolgd door alle parameters die de actie accepteert in key/value pairs.

Bijvoorbeeld de volgende controller actie:

	<?php

class Stock_Controller extends Zend_Controller_Action {
	public function indexAction() {
		// value
		$this->getRequest()->getParam('filter');
	}
}

Deze kunnen we benaderen middels de volgende route:

/stock/index/filter/value

Database

De intentie is om de database gedurende de migratieperiode niet aan te passen. Eventueel zouden er nieuwe tabellen kunnen worden geïntroduceerd maar deze zijn niet te gebruiken in Zend Framework.

We creëren in Laravel een Eloquent representatie van de huidige database en gebruiken dit om de nieuwe functionaliteit op te schrijven. Zend Framework werkt met een `core_entity` tabel. Hier staan alle models in die door de applicatie worden aangemaakt. Deze tabel heeft vervolgens weer een one-to-many relatie met een `core_entity_meta` tabel.

Wij hebben hier een simpele `HasMeta` trait voor geschreven die ervoor zorgt dat er bij het create event van een model altijd entity record bestaat.

	trait HasMeta
{
    public static function bootHasMeta()
    {
        self::created(function($model) {

            $createdBy = null;

            if (auth('admin')->check()) {
                $createdBy = auth('company')->user()->entity_id;
            } else {
                if (auth('company')->check()) {
                    $createdBy = auth('company')->user()->entity_id;
                }
            }

            $entity = new Entity([
                'type' => str_replace('_', '-', $model->table),
                'created_by' => $createdBy
            ]);

            $model->entity()->save($entity);
            $entity = $entity->refresh();
            $model->update(['entity_id' => $entity->id]);
        });
    }
}

Migraties

Om ons leven makkelijk te maken biedt Laravel natuurlijk migraties. Een prettige manier om het database schema vast te leggen in onze repository. Hiervoor hebben wij de https://github.com/Xethron/migrations-generator package gebruikt. Deze package genereert o.b.v. een bestaande database alle migraties. Handig!

Tot slot

Deze manier van werken stelt ons in staat om te werken met tooling waar wij vertrouwd mee zijn en die ook meegaan met de tijd. Dit werkt erg prettig en zorgt voor een goede DX.

Echter zie je dat er ook “creatieve oplossingen” vereist zijn om deze structuur te laten werken. Dat is geen ramp zolang dit binnen de perken blijft.

Pssst. Ben jij ook fan van legacy opruimen? Wij zoeken nog getalenteerde legacyknallers! Check https://impres.nl/vacatures