Laravel Cafè #1 - Eloquent: Relazioni ed Eventi

Laravel Cafè N.1 - 25 Ottobre 2016 - Filippo ci parla di Eloquent, di relazioni e di eventi, analizzando approcci e problematiche.
francesco
Filippo Galante
26/10/2016 in Tutorial


Benvenuto al Laravel Cafè! Ogni settimana proporremo un nuovo argomento sul mondo Laravel, quindi trova un posto libero, prendi un caffè e condividi le tue opinioni con la comunità! L'idea è di creare un punto di discussione. Se hai qualche perplessità sull'argomento trattato, leggi fino alla fine e fai una domanda usando il forum! Cercheremo di risponderci a vicenda e di aiutarci, ed il confronto ci farà crescere tutti un po' di più.

Di mercoledì in mercoledì parleremo di qualcosa di diverso, quindi tornaci a trovare! Potresti dare una mano a qualcuno in difficoltà, o ricevere tu un aiuto in caso di problemi! Dai, siediti, il primo caffè lo offriamo noi.

L'Argomento

Iniziamo questa nuova rubrica con uno dei fondamenti dell'architettura MVC: i modelli. Laravel ha sempre mantenuto una documentazione molto completa a riguardo, ma un pochino frammentata in esempi non sempre collegati fra loro. Per un developer alle prime armi, un modello in Laravel si traduce in poco più di una semplice "raffigurazione" di una tabella di un database e delle sue relazioni, con al massimo un paio di scopes per automatizzare una particolare query, ad esempio per ritrovare tutti gli utenti appartenenti ad un certo gruppo, piuttosto che le ultime news pubblicate. In verità un modello può diventare parte attiva anche della business logic, evitando di dover replicare operazioni CRUD all'interno di uno o più controller semplicemente gestendo il fire degli eventi che Eloquent lancia.

Oggi vi propongo quindi la mia visione su come strutturare un modello in Laravel 5.3, i suoi eventi e le sue relazioni e come modificarle durante un cambio di stato del modello.

Il Codice

Iniziamo con la struttura del filesystem. Personalmente preferisco creare varie sotto cartelle (e di conseguenza namespaces che andranno inclusi nei vari controller), anziché salvare tutto nella cartella app. Questa operazione rende la cartella meno affollata e dispersiva, soprattutto su applicazioni di una certa complessità. Di seguito trovi lo schema utilizzato per l'esempio di oggi.

app/
 |-- Models/                    <-- Cartella base dei modelli
     |-- Core/                  <-- Cartella gruppo modelli Core
         |-- User.php           <-- Classe base creata automaticamente da Laravel
         |-- Registry.php       <-- Classe rappresentante il modello Anagrafica (relazione 1:1 con User)
         |-- Note.php           <-- Classe rappresentante il modello Nota
     |-- Observers/             <-- Cartella degli Observers
         |-- Core/              <-- Cartella omissibile, ma mi piace mantenere la struttura
             |-- RegistryObserver.php   <-- Observer del modello Registry
             |-- NoteObserver.php       <-- Observer del modello Note
     |-- Traits/
         BaseModels.php          <-- Trait utilizzato per le relazioni di creazione, modifica ed eliminazione

Utilizzando il comando php artisan make:model Models/Gruppo/Modello Laravel ci fa il favore di inserire il modello nella cartella corretta e con il relativo namespace.

Per rendere il codice di un modello più pulito e ordinato ho preso l'abitudine di creare dei Trait specifici (prima tendenzialmente estendevo classi o implementavo interfacce). In alcuni casi ho utilizzato i Trait per creare dei semplici metodi pubblici di supporto (ex. un prodotto ha dimensione, peso e quantità ordinate, con un metodo pubblico si può recuperare questi dati e formattarli in una stringa di riepilogo localizzata)

File BaseModels.php

namespace App\Models\Traits;

trait BaseModels
{

    /**
     * Get the create user record associated with the model.
     */
    public function createUser()
    {
        return $this->belongsTo('App\Models\Core\User', 'id_user_create');
    }

    /**
     * Get the update user record associated with the model.
     */
    public function updateUser()
    {
        return $this->belongsTo('App\Models\Core\User', 'id_user_update');
    }

    /**
     * Get the delete user record associated with the model.
     */
    public function deleteUser()
    {
        return $this->belongsTo('App\Models\Core\User', 'id_user_delete');
    }

    /**
     * Local scope, retrieve by create user id
     *
     * @param type $query
     * @param type $id_user
     */
    public function scopeCreatedBy($query, $id_user)
    {
        $query->where('id_user_create', '=', $id_user);
    }

}

In una visione più avanzata dei modelli, la gestione dei timestamps di un modello è complementare alla gestione degli utenti di creazione, modifica ed eliminazione. Per questo motivo ho deciso di includere questo Trait nel codice d'esempio dato che si sposa perfettamente con quanto andrò ad implementare negli eventi del modello.

Model

La classe Note è molto semplice, non ha relazioni, se non quelle dettate dal trait e gli attributi indispensabili...

File Note.php

namespace App\Models\Core;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Traits\BaseModels;

class Note extends Model
{

    use BaseModels,
        SoftDeletes;

    /**
     * The attributes that should be mutated to dates.
     *
     * @var array
     */
    protected $dates = [
        'created_at', 'updated_at', 'deleted_at'
    ];

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillabe = [
        'title', 'body'
    ];

    /**
     * The attributes that aren't mass assignable.
     *
     * @var array
     */
    protected $guarded = [
        'id_user_create', 'id_user_update', 'id_user_delete'
    ];

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

}

Non c'è molto da dire, tranne che ci servirà per l'esempio finale... Focalizziamoci invece sulle due possibili gestioni degli eventi di un modello. Il primo è l'utilizzo degli observers, che ho implementato per la classe Registry. Il secondo è il metodo "classico", implementato all'interno del metodo boot() della classe User.

N.B.: Nella documentazione di Laravel 5.1 e 5.2 non vengono citati da nessuna parte gli Observers, ma ci sono e funzionano sempre allo stesso modo della 5.0.

File Registry.php

namespace App\Models\Core;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Traits\BaseModels;

class Registry extends Model
{

    use BaseModels,
        SoftDeletes;

    /**
     * The attributes that should be mutated to dates.
     *
     * @var array
     */
    protected $dates = [
        'created_at', 'updated_at', 'deleted_at'
    ];

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillabe = [
        'id_user', 'name', 'surname'
    ];

    /**
     * The attributes that aren't mass assignable.
     *
     * @var array
     */
    protected $guarded = [
        'id_user_create', 'id_user_update', 'id_user_delete'
    ];

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

    /**
     * Get the user record associated with the registry.
     */
    public function user()
    {
        return $this->belongsTo('App\Models\Core\User', 'id_user');
    }

}

Nella descrizione della classe qui di seguito trovate il workflow completo degli eventi di un modello, ma per questo esempio sono stati riportati solamente i metodi utili.

File Observers\Core\RegistryObserver.php

namespace App\Models\Observers\Core;

use App\Models\Core\Registry;
use Auth;

/**
 * https://laravel.com/docs/5.3/eloquent#events
 *
 * Eloquent models fire several events, allowing you to hook into various points
 * in the model's lifecycle using the following methods: creating, created,
 * updating, updated, saving, saved, deleting, deleted, restoring, restored.
 * Events allow you to easily execute code each time a specific model class is
 * saved or updated in the database.
 *
 * Whenever a new model is saved for the first time, the creating and created
 * events will fire. If a model already existed in the database and the save
 * method is called, the updating / updated events will fire. However, in both
 * cases, the saving / saved events will fire.
 *
 * EVENTS WORKFLOW:
 *
 * - CREATE
 * - saving
 * - creating
 * - created
 * - saved
 *
 * - SAVE
 * - saving
 * - updating
 * - updated
 * - saved
 *
 * - DELETE
 * - deleting
 * - deleted
 *
 * - RESTORE
 * - restoring
 * - saving
 * - saved
 * - restored
 *
 * - FORCE DELETE
 * - deleting
 * - deleted
 *
 */
class RegistryObserver
{

    /**
     * Listen to the Registry creating event.
     *
     * @param  Registry  $registry
     * @return void
     */
    public function creating(Registry $registry)
    {
        // Check if user in session
        if (Auth::check()) {
            // Add create user
            $registry->createUser()->associate(Auth::user());
        }
    }

    /**
     * Listen to the Registry updating event.
     *
     * @param  Registry  $registry
     * @return void
     */
    public function updating(Registry $registry)
    {
        // Check if user in session
        if (Auth::check()) {
            // Add update user
            $registry->updateUser()->associate(Auth::user());
        }
    }

    /**
     * Listen to the Registry deleting event.
     *
     * @param  Registry  $registry
     * @return void
     */
    public function deleting(Registry $registry)
    {
        // Check if model is soft deleting or force deleting
        if (!$registry->isForceDeleting()) {
            // Check if user in session
            if (Auth::check()) {
                // Add delete user
                $registry->deleteUser()->associate(Auth::user());
            }
        }
    }

    /**
     * Listen to the Registry created event.
     *
     * @param  Registry  $registry
     * @return void
     */
    public function restored(Registry $registry)
    {
        // Remove delete user
        $registry->deleteUser()->dissociate();

        // Add update user
        $registry->updateUser()->associate(Auth::user());

        $registry->save();
    }

}

Per rendere operativo il nostro observer va aggiunto il seguente codice all'interno del metodo boot() della classe App\Providers\AppServiceProvider.

Come si può vedere, è presente anche l'observer per il modello Note che ho omesso dato che è identico a quello del modello Registry

public function boot()
{
    Registry::observe(RegistryObserver::class);
    Note::observe(NoteObserver::class);
}

Dal momento in cui l'observer è attivo, quando verrà eseguito il fire di ciascun evento, automaticamente verrà gestito l'inserimento (o la rimozione nel caso del restore) degli utenti di creazione, modifica ed eliminazione. Vediamo ora la maniera più classica di gestire gli eventi, direttamente dal modello User.

File: User.php

namespace App\Models\Core;

use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Traits\BaseModels;
use Auth;

class User extends Authenticatable
{

    use Notifiable,
        BaseModels,
        SoftDeletes;

    /**
     * The "booting" method of the model.
     *
     * @return void
     */
    protected static function boot()
    {
        parent::boot();

        static::creating(function(User $user) {
            // Check if user in session
            if (Auth::check()) {
                // Add create user
                $user->createUser()->associate(Auth::user());
            }
        });

        static::updating(function(User $user) {
            // Check if user in session
            if (Auth::check()) {
                // Add update user
                $user->updateUser()->associate(Auth::user());
            }
        });

        static::deleting(function(User $user) {
            // Check if model is soft deleting or force deleting
            if (!$user->isForceDeleting()) {
                // Check if user in session
                if (Auth::check()) {
                    // Add delete user
                    $user->deleteUser()->associate(Auth::user());
                }
            }
        });

        static::deleted(function(User $user) {
            if ($user->forceDeleting) {
                // Force delete relations
                $user->registry()->withTrashed()->forceDelete();

                $notes = Note::withTrashed()->createdBy($user->id)->get();

                foreach ($notes as $note) {
                    $note->forceDelete();
                }
            } else {
                // Soft delete relations
                $user->registry->delete();

                $notes = Note::withTrashed()->createdBy($user->id)->get();

                foreach ($notes as $note) {
                    $note->delete();
                }
            }
        });

        static::restored(function(User $user) {
            $user->deleteUser()->dissociate();

            // Add update user
            $user->updateUser()->associate(Auth::user());

            $user->save();

            // Restore relations
            $user->registry()->withTrashed()->restore();

            $notes = Notes::withTrashed()->createdBy($user->id)->get();

            foreach ($notes as $note) {
                $note->restore();
            }
        });
    }

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

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

    /**
     * Get the registry record associated with the user.
     */
    public function registry()
    {
        return $this->hasOne('App\Models\Core\Registry', 'id_user');
    }

}

Come potete vedere non cambia praticamente nulla dall'utilizzo di un observer, a parte la possibilità di poter richiamare la variabile protected $forceDeleting senza dover passare per il getter.

Il Test

Siamo giunti al momento della domandona del giorno: funzioneranno correttamente questi eventi? Verranno correttamente modificati modelli e relazioni "a cascata"?

Per rispondere a questa domanda vi riporto il codice del test che ho eseguito su tre routes create appositamente per simulare le normali operazioni di un controller. Nel dettaglio il test è suddiviso in quattro parti:

  1. La prima richiama la route "store" che crea un modello Registry e un modello Note e verifica che gli utenti di creazione siano corretti;
  2. La seconda richiama la route "update" che modifica un modello Registry e crea un nuovo modello Note e verifica che gli utenti di modifica/creazione siano corretti;
  3. La terza richiama la route "delete" che elimina un modello User e verifica che venga inserito l'utente di eliminazione durante il soft delete sul modello Registry ad esso collegato, così come per tutte le note create dall'utente del test;
  4. La quarta richiama nuovamente la route "delete" che questa volta andrà ad eliminare definitivamente dal database il modello User e a cascata le sue relazioni;

use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;

class ExampleTest extends TestCase
{

    use DatabaseMigrations;

    /**
     * A basic functional test example.
     *
     * @return void
     */
    public function testModels()
    {
        $this->visit('/')
                ->see('Laravel');

        // Create user
        $user = factory(App\Models\Core\User::class)->create();

        // Test creation
        $response_create = $this->actingAs($user)
                ->json('POST', '/storeRegistry', ['name' => 'John', 'surname' => 'Doe'])
                ->seeStatusCode(200)
                ->decodeResponseJson();

        $registry_id_create_user = $response_create['registry']['create_user']['id'];
        $create_note_id_create_user = $response_create['note']['create_user']['id'];

        $this->assertEquals($user->id, $registry_id_create_user);
        $this->assertEquals($user->id, $create_note_id_create_user);

        // Test update
        $response_update = $this->actingAs($user)
                ->json('POST', '/updateRegistry', ['id' => $response_create['registry']['id'], 'name' => 'John', 'surname' => 'Dooe'])
                ->seeStatusCode(200)
                ->decodeResponseJson();

        $registry_id_update_user = $response_update['registry']['update_user']['id'];
        $update_note_id_create_user = $response_update['note']['create_user']['id'];

        $this->assertEquals($user->id, $registry_id_update_user);
        $this->assertEquals($user->id, $update_note_id_create_user);

        // Test soft delete
        $response_soft_delete = $this->actingAs($user)
                ->json('POST', '/deleteUser', ['id' => $user->id])
                ->seeStatusCode(200)
                ->decodeResponseJson();

        $user_id_delete_user = $response_soft_delete['user']['delete_user']['id'];
        $registry_id_delete_user = $response_soft_delete['user']['registry']['delete_user']['id'];

        $this->assertEquals($user->id, $user_id_delete_user);
        $this->assertEquals($user->id, $registry_id_delete_user);

        foreach (App\Models\Core\Note::createdBy($user->id)->with('delete_user')->get() as $note) {
            $this->assertEquals($user->id, $note->delete_user->id);
        }

        // Test force delete
        $this->actingAs($user)
                ->json('POST', '/deleteUser', ['id' => $user->id])
                ->seeJsonEquals([
                    'deleted' => true,
        ]);
    }

}

Se volete ordinare qualcos'altro per provare ad implementare il codice e vedere se il test va a buon fine, questo è il momento giusto per farlo!

... ed ora?

Cosa ne pensate di questa modifica a cascata dei modelli? L'avete già utilizzata o preferite seguire altre vie per gestire le relazioni?

Sempre riguardo le relazioni e la loro gestione, quali sono i problemi più comuni che vi siete ritrovati ad affrontare?

In questo esempio abbiamo visto solo una delle possibili implementazioni degli eventi di un modello. Tuttavia, nel mezzo possono essere inserite molte altre automazioni, come l'invio di una mail di benvenuto alla creazione dell'utente. Voi utilizzate questi eventi? Preferite la maniera "classica" o la creazione degli Observer?

Lo scopo dei Laravel Cafè è il confronto. Per questo motivo, fatevi avanti e fatemi sapere cosa ne pensate! Non solo: se state avendo un problema collegato a model e relazioni, lasciate un commento e vediamo insieme cosa si può fare!

E non scordate che c'è anche lo Slack di Laravel-Italia! Vi aspettiamo!