Welkom op julesgraus.nl

Google Podcasts

13-11-2023

Livewire 3 synthesizers

Terugblikken op Livewire 2

In Livewire 2 kon je rechtstreeks de wire:model directive toepassen op properties van eloquent modellen. Neem bijvoorbeeld dit voorbeeld Livewire 2 component:

use App\Post;
 
class PostForm extends Component
{
    public Post $post;
}

Je kon direct de properties van het Post Model koppelen aan inputs met de wire:model directive:

<form>
    <input type="text" wire:model="post.title">
    <textarea wire:model="post.content"></textarea>
</form>

Dit kan natuurlijk handig zijn omdat je direct zonder tussenstappen je Post model kan aanpassen. Als je klaar bent met aanpassen, hoe je na validatie alleen de save functie op je Post model te callen om het model op te slaan!

Upgraden naar Livewire 3

In Livewire 3 kan dit standaard niet meer op deze manier. Als je kijkt in de upgrade guide van versie 2 naar 3, zie je dit staan:

In Livewire 3, binding directly to Eloquent models has been disabled in favor of using individual properties, or extracting Form Objects.

Maar wat je op die plek in de handleiding niet terugvindt, is dat er in Livewire 3 er ook nog de optie is om voor synthesisers te kiezen. Een synthesizer is verantwoordelijk voor het hydraten en dehydraten van een bepaald property op je Livewire component. Als je die op de juiste manier gebruikt, dan kun je wel weer direct aan eloquent models binden zoals in Livewire 2.

Dehydrate en hydrate refresher

Laten we heel even kijken naar wat hydrate en dehydrate ook al weer inhoud in de context van livewire. Deze kennis moeten we namelijk beheersen om met synthesisers te werken.

Met hydraten wordt bedoeld dat alle public properties van je livewire component, omgezet worden naar json. Om vervolgens door het javascript gedeelte van Livewire gebruikt te worden in de browser. Als je vervolgens in de browser een request veroorzaakt naar de backend door bijvoorbeeld op een knop te klikken met een wire:click directive, word de data van het javascript gedeelte ge-stringyfied naar de backend gestuurd. Die maakt dan een nieuwe instance aan van het component, en gebruikt dan de data die naar de backend gestuurd werd om de instance weer te vullen.

Doordat de state hetzelfde is aan de frontend en aan de backend, hoef je deze zelf niet te syncen met bijvoorbeeld een json api. En hierdoor kunnen bijvoorbeeld livewire directives zoals wire:model.live meteen na een wijziging de verandering zien, en syncen met de backend. En kan jij focussen op het schrijven van PHP-code, zonder dat je per se HTML-formulieren of javascript hoeft te gebruiken.

Meer informatie over dit hydrate en dehydrate process, vind je in de handleiding.

Je eerste synthesiser

Stel je hebt een category eloquent model. En je wil die in een livewire component gebruiken op deze manier:

<?php declare(strict_types=1);

namespace \App\Category;

use App\Models\Category;
use Livewire\Component;


class CategoryManager extends Component
{
    public Category $category
}

Synthesiser class en registratie

Om een synthesizer voor dit Category model te maken, maak je er eerst een class voor op deze manier:

<?php declare(strict_types=1);

namespace App\Categories\Synthesizers;

use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;

class Category extends Synth {
  
}

Vervolgens registreer je de Synthesizer in de boot functie van een Serviceprovider:

<?php declare(strict_types=1);

namespace App\Providers;

use App\Categories\Synthesizers\Category;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;

class LivewireServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Livewire::propertySynthesizer(Category::class);
    }
}

Match

Livewire weet nu dat er een Synthesiser is met de naam "Category". Maar weet nog niet wanneer hij die mag gebruiken. Daarvoor is de match methode uitgevonden. Als je deze functie op onderstaande manier toevoegt aan je synthesiser, zal elk Livewire Component met een public property van het type Category, de synthesizer gebruiken:

static function match($target): bool
{
    return $target instanceof CategoryModel;
}

Let op dat je in de match functie het eloquent model gebruikt in de instanceof check. Niet de synthesiser.

Dehydrate en Hydrate

Nu komen we tot de kern van de synthesiser. De dehydrate en hydrate functies. Laten we beginnen met de dehydrate functie. Die moet een array retourneren die omgezet kan worden naar json. In ons voorbeeld heeft het category eloquent model 2 properties / attributes. Namelijk id en name. We implementeren hem als volgt:

public function dehydrate(CategoryModel $target): array
{
    return [
        [
            'id' => $target->id,
            'name' => $target->name,
        ],
        []
    ];
}

Je ziet dat we ook nog een 2de lege array in de te retourneren array hebben zitten. Waarvoor deze precies dient zien we verderop. Je moet deze altijd toevoegen.

Als de JSON-data weer van de frontend terug komt, word de hydrate functie in de synthesiser aangeroepen. Deze moet de data terug omzetten naar een Category model:

public function hydrate(array $value): ?CategoryModel
{
    $instance = new CategoryModel();
    $instance->id = $value['id'];
    $instance->name = $value['name'];

    return $instance;
}

Key

Vervolgens zorg je ervoor dat boven in de class een public static property met de naam $key is gedefinieerd met een unieke string waarde.

public static string $key = 'ctg';

Livewire gebruikt deze key in "subsequent requests" om te bepalen welke synthesiser hij moet gebruiken. Zolang deze waarde tussen al je synthesisers uniek is, is hij goed. Zelfs als je in dit geval in meerdere components, meerde public properties hebt, die deze synthesiser gebruiken.

Done! Je eerste synthesiser is nu klaar voor gebruikt. Maar hij is echter niet geschikt om te gebruiken met wire:model. Daarover lees je meer in het volgende hoofdstuk

Get en Set methodes

Als je wire:model wil gebruiken op een public property die een synthesiser gebruikt, moet je 2 methodes toevoegen aan je synthesizer. De eerste is set:

public function set(&$target, $key, $value): void
{
    /** @var CategoryModel $target */
    match($key) {
        'id' => $target->id = $value,
        'name' => $target->name = $value,
        default => 'setting '.$key.' not supported'
    };
}

Als je bijvoorbeeld een HTML input veld hebt, met daarop een wire:model directive, dan zal deze set functie aangeroepen worden als er in getyped is. De set functie krijgt dan 3 argumenten mee. target is in dit geval het Category eloquent model waarvoor de synthesiser gemaakt is. De key variabele bevat de naam van het attribuut wat geset moet worden, naar de gegeven value variabele.

De get functie werkt precies andersom. Op het moment dat de input gevuld moet worden, zal de get methode gebruikt worden om de waarde voor de input veld te bepalen.

public function get(&$target, $key): mixed
{
    /** @var CategoryModel $target */
    return match($key) {
        'id' => $target->getKey(),
        'name' => $target->name,
        'exists' => $target->exists,
    };
}

Dingen die niet in de handleiding staan, maar die je wel nodig hebt!

De handleiding van livewire is op dit moment niet compleet wat betreft synthesisers. Hieronder leg ik uit wat er mist, en waarom dat dat super handig is om toch te weten.

Geneste modellen / relaties (de)hydraten

Het eerste probleem dat ik behandel, is dat als een eloquent model relaties heeft met andere modellen, en deze zijn ge-eagerload, dat deze relaties niet meer present zijn na het hydraten. Op zich logisch als je erover nadenkt, want in bovenstaand voorbeeld, deden we er immers niets mee. Laten we eens bekijken hoe we dat werkend maken wanneer het _Categorie_model een relatie zou hebben met een User model.

Het eerste wat je hoort te doen is ook voor het User model een synthesiser te maken zoals je voor de categorie gedaan hebt. Daarna pas je de dehydrate functie voor de Category aan. Hij accepteert namelijk een 2de argument van het type Closure:

public function dehydrate(CategoryModel $target, Closure $dehydrateChild): array
{
    return [
        [
            'id' => $target->id,
            'name' => $target->name,
            'user' => $target->relationLoaded('user') ? $dehydrateChild('user', $target->user) : null,
        ],
        []
    ];
}

Zoals je ziet kan je dat 2de argument (Hier dehydrateChild genoemd), callen met 2 parameters. Een referentie (string) naar keuze, en het te dehydraten relatie / child model. In dit geval dus een User model. Omdat je eerder een synthesiser hebt geregistreerd voor je user model, zal Livewire begrijpen hoe hij je user model moet dehydraten.

Hydraten gaat op bijna dezelfde manier:

public function hydrate(array $value, array $meta, Closure $hydrateChild): ?CategoryModel
{
    $instance = new CategoryModel();
    $instance->id = $value['id'];
    $instance->name = $value['name'];

    $instance->setRelation('user', $hydrateChild('user', $value['user']));

    return $instance;
}

Je ZOU kunnen overwegen om bij het hydraten, het model op te halen uit de database op deze manier: CategoryModel::query($value['id'']). Maar realiseer je dat het misschien niet zo'n goed idee is. Je doet dan een query naar de database die niet persee nodig is.

Meta data, $model->is() en $model->exists

De implementatie van de hydrate functie hierboven heeft soms een nadeel. Nadat het model gehydrate is, zal een is call om the controleren of het model dezelfde id, tabel en database connectie heeft als een gegeven model, niet meer werken. En de exists property op je model retourneert false, zelfs als je model in de database bestaat. Laten we eerst kijken naar de is methode.

Zo ziet de implementatie van de is methode op een model eruit:

public function is($model)
{
    return ! is_null($model) &&
        $this->getKey() === $model->getKey() &&
        $this->getTable() === $model->getTable() &&
        $this->getConnectionName() === $model->getConnectionName();
}

De getKey methode retourneert standaard de waarde van de id kolom. getTable de naam van de database tabel waar je model in wordt opgeslagen. En getConnectionName de naam van de gebruikte database connectie. Bijvoorbeeld sqlite, mysql, pgsql enzovoorts.

Om de is methode werkend te maken, moeten we de table name en connection name bij het hydraten en dehydraten meenemen. En precies daarvoor is die 2de, standaard lege array bedoeld bij het dehydraten.

public function dehydrate(CategoryModel $target, Closure $dehydrateChild): array
{
    return [
        [
            'id' => $target->id,
            'name' => $target->name,
            'user' => $target->relationLoaded('user') ? $dehydrateChild('user', $target->user) : null,
        ],
        //Deze array was in vorige voorbeelden leeg. Maar kunnen we gebruiken om meta data in te stoppen.
        [
            'con' => $target->getConnectionName(),
            'tbl' => $target->getTable()
        ]
    ];
}

De key namen "con" en "tbl" zijn arbitrair. Gebruik alleen de "s" key niet. Als je dat doet, overschrijft Livewire je waarde. Deze array krijg je bij het hydraten terug en kun je gebruiken om de tabelnaam en connectie naam in te stellen. Waardoor de is methode weer werkt:

public function hydrate(array $value, array $meta, Closure $hydrateChild): ?CategoryModel
{
    $instance = new CategoryModel();
    $instance->id = $value['id'];
    $instance->name = $value['name'];

    $instance->setRelation('user', $hydrateChild('user', $value['user']));
    
    $instance->setConnection($meta['con']);
    $instance->setTable($meta['tbl']);

    return $instance;
}

Op deze manier kun je bijvoorbeeld ook de exists property fixen. In de meta data array kun je van allerlei data kwijt die niet de data van het model zelf is. Maar informatie over of behorende tot die data geeft. In dit geval de tabel en connectie naam, en of de data in de database staat. Soms wordt deze meta data ook gebruikt om de class name in bij te houden. Bijvoorbeeld als je synthesiser voor verschillende implementaties van een interface moet werken.

Source diving!

Een kijkje naar de kern van Livewire zelf. Voor wat interessante stukjes code, die relevant zijn voor deze blogpost.

Meegeleverde synthesisers

Livewire wordt geleverd met een aantal synthesizers. Kijk maar eens in deze map.

Synthesiser Doel
ArraySynth Arrays
CarbonSynth DateTime, DateTimeImmutable, Carbon, CarbonImmutable, \Illuminate\Support\Carbon datum / tijd objecten
CollectionSynth Illuminate\Support\Collection objecten
EnumSynth Backed enums
IntSynth Lege strings toegewezen aan properties van het type int
StdClassSynth stdClass objecten
StrinableSynth Illuminate\Support\Stringable

Let op met de CollectionSynth. Deze werkt alleen met instances van Illuminate\Support\Collection. En niet met Illuminate\Database\Eloquent\Collection. Als je namelijk eloquent modellen uit de database ophaalt dan krijg je een collection van het type Illuminate\Database\Eloquent\Collection. Indien nodig kun je die wel omzetten naar het type Illuminate\Support\Collection. Dat doe je door op de collection de toBase method te callen.

(De)hydrate child relaties

Om het dehydraten van children wat beter te begrijpen, zou je kunnen kijken naar de broncode:

protected function dehydrate($target, $context, $path)
{
    if (Utils::isAPrimitive($target)) return $target;

    $synth = $this->propertySynth($target, $context, $path);

    [ $data, $meta ] = $synth->dehydrate($target, function ($name, $child) use ($context, $path) {
        return $this->dehydrate($child, $context, "{$path}.{$name}");
    });

    $meta['s'] = $synth::getKey();

    return [ $data, $meta ];
}

Dit is een stukje uit de HandleComponents class. Deze wordt gebruikt door de LivewireManager.

Als je goed kijkt, zie je de regel met $synth->dehydrate staan. Dit is de regel waar Livewire jouw dehydrate functie aanroept op jouw synthesiser. Het 2de argument is een functie. Dit is de "dehydrateChild" closure die ik eerder in deze post al beschreef vanuit het perspectief van de category synthesiser.

Zoals je ziet verwacht deze closure een child als 2de argument. Dat is bijvoorbeeld dat User model uit het eerdere voorbeeld. Livewire geeft dit child argument simpelweg gewoon weer door aan zijn eigen dehydrate functie. Hierdoor kan hij via andere registreerde synthesizers dat model dehydraten.

Volg daarvoor de $synth->propertySynth call om te zien hoe hij dat doet. De hydrate functie werkt op eenzelfde manier als de dehydrate.