Il casting degli attributi nei Modelli

In PHP, non è necessario specificare il tipo di dato di una variabile quando la si dichiara. Il parser PHP lo imposta automaticamente per noi. Tuttavia, il tipo di dato può essere convertito in un altro. A cosa ci può servire questo?

Ad esempio, può capitare che quando un modulo viene inviato al server, uno dei campi deve essere “castato” prima di poter essere utilizzato in modo significativo. O anche si può verificare la situazione in cui il dato deve essere recuperato dal database e prima di essere manipolato dal controller è necessario effettuare un'operazione di casting.

I modelli di Laravel dispongono di una proprietà denominata cast che accetta come parametri un’array, le cui chiavi sono i nomi dei campi della tabella mentre il relativo valore è il tipo di dato in cui si vuole castare tale campo.

class MyModel extends Model
{
    protected $casts = [
    'field_1' => 'array',
    'field_2' => 'boolean',
    'field_3' => 'string',
    'field_4' => 'collection'
    ];
}

Come vediamo dalle esempio è possibile usare i classici tipi di dato ma anche delle funzionalità tipiche di laravel.

Non solo, possiamo creare delle classi per effettuare un vero e proprio custom cast. Vediamo come procedere facendo un’implementazione alla nostra entità Car. In primo luogo creiamo una migration per aggiungere un campo json alla tabella cars

php artisan make:migration add_options_field_to_cars_table

Di seguito i metodi up() e down() della migration

public function up()
{
    Schema::table('cars', function (Blueprint $table) {
        $table->json('options')->nullable()->after('registered_at');
    });
}
public function down()
{
    Schema::table('cars', function (Blueprint $table) {
        $table->dropColumn('options');
    });
}

Facciamo girare la migration e dopodiché creiamo il nostro Custom cast

php artisan make:cast CustomCast

La classe sarà creata all'interno della cartella app/Casts.

Apriamola e vediamo che al suo interno ci sono due metodi: get() e set(). È abbastanza intuitivo che il primo sarà richiamato in fase di lettura mentre il secondo sarà richiamato quando il campo viene valorizzato.

In questa classe impostiamo una proprietà privata

protected $default = [
'serial_number' => null,
'color' => null,
'power_supply' => null,
];

E gestiamo il getter e il setter in maniera molto banale

public function get($model, string $key, $value, array $attributes)
{
    return is_null($value) ? $this->default : json_decode($value, true);
}
public function set($model, string $key, $value, array $attributes)
{
    return is_array($value) ? json_encode($value) : json_encode($this->default);
}

Adesso apriamo il modello Car, importiamo la classe appena creata e utilizziamola per castare il campo options

namespace App\Models;
use App\Casts\CustomCast;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Car extends Model
{
    use HasFactory;
    protected $casts = [
    'options' => CustomCast::class
    ];
    ...
}

Di seguito un esempio su come testare il nostro casting

$car = Car::find(1);
// Qui interviene il metodo Get della Classe CustomCast
$car->options;
// Qui interviene il metodo Set della Classe CustomCast
$car->options = ['color' => 'yellow'];

Casting delle date

Abbiamo la possibilità di fare un casting automatico per quanto riguarda i campi di tipo data. Di default Laravel le gestisce come stringhe per cui non è possibile fare le operazioni tipiche del datetime.

Possiamo modificare facilmente questo comportamento, specificando all'interno del modello la proprietà $dates e impostando come valore un array contenente tutti i campi che vogliamo gestire come data.

Ad esempio nel modello Car, farò il casting del campo registered_at in questo modo

protected $dates = [
'registered_at'
];

Accessors

Gli accessors rappresentano un altro approccio per manipolare gli attributi dei modelli in fase di lettura. Nelle versioni passate di Laravel, Accessors e Mutators dovevano essere assegnati con metodi diversi. Adesso è stato introdotto un nuovo sistema che ci permette di definire il getter e il setter di un attributo, all'interno di un unico metodo.

Personalmente faccio un largo uso degli accessors per formattare le date con un formato leggibile per gli utenti italiani. Ad esempio, definiamo un accessor nel modello User per formattare la data di validazione della email. Il campo si chiama email_verified_at, quindi per convenzione, il nostro metodo deve chiamarsi emailVerifiedAt()

use Illuminate\Database\Eloquent\Casts\Attribute;
use Carbon\Carbon;
protected function emailVerifiedAt() : Attribute
{
    return new Attribute(
    get: fn ($value) => $value ? Carbon::parse($value)->translatedFormat('d/m/Y H:i') : null
    );
}

Possiamo anche creare dei campi logici, che fisicamente non esistono nel database ma attraverso un accessor possono essere costruiti e iniettati nell'istanza del modello.

Ad esempio potremmo creare, sempre nel modello User, un accessor full_name. Un campo che non esiste in tabella ma che possiamo ricavare concatenando i campi first_name e last_name

protected function fullName() : Attribute
{
    return new Attribute(
    get: fn () => $this->first_name . ' ' . $this->last_name
    );
}

Testiamo il funzionamento su Tinker

Come vedete, gli effetti degli accessors si notano solo nel momento in cui vengono invocati. In alcune situazioni, definire un accessor per un campo realmente esistente nel database, può portare una serie di problemi.

Si pensi ad esempio alle operazioni di storicizzazione dei record, in cui di solito si prende un record dalla tabella live e si clona nella tabella delle storicizzazioni. Se nel modello sono definiti degli accessors, soprattutto per quanto riguarda le date, si potrebbero verificare dei problemi in fase di scrittura. Per risolvere questo piccolo inconveniente si utilizza il metodo getRawOriginal() passandogli come parametro il nome del campo.

Mutators

I mutators sono l’altra funzionalità relativa al casting attributi dei modelli. Come test, riprendiamo la situazione che abbiamo paventato alla fine del paragrafo precedente: Abbiamo una tabella da storicizzare con i dati con i record della tabella live.

Velocemente creiamo modello e migration

php artisan make:model UserHistory -m

I campi della migrations saranno gli stessi della tabella users, senza token e password, con l'aggiunta della foreign key per legare i records.

public function up()
{
    Schema::create('user_histories', function (Blueprint $table) {
        $table->id();
        $table->unsignedBigInteger('user_id');
        $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
        $table->string('first_name');
        $table->string('last_name');
        $table->string('email')->unique();
        $table->timestamp('email_verified_at')->nullable();
        $table->timestamps();
    });
}

Aggiungiamo la proprietà guarded nel modello UserHistory

class UserHistory extends Model
{
    use HasFactory;
    protected $guarded = [];
}

E aggiungiamo nel modello User la relazione Has Many con il modello UserHistory

public function histories()
{
    return $this->hasMany(UserHistory::class);
}

A questo punto vediamo come storicizzare un utente qualsiasi. Come già detto, non abbiamo bisogno di tutti i campi, quindi elimineremo quelli che non ci servono con il metodo makeHidden().

Dopodiché, attraverso la relazione che abbiamo appena creato, creeremo il nuovo record nella tabella user_histories

$user = User::find(1);
$data = $user->makeHidden([
'id',
'password',
'remember_token',
'created_at',
'updated_at'
])->toArray();
$user->histories()->create($data);

E come paventato riceveremo il seguente errore

Per risolvere, possiamo creare un mutator nel modello UserHistory che casti nuovamente il campo email_verified_at.

protected function emailVerifiedAt() : Attribute
{
    return new Attribute(
    set: function ($value) {
        $new_value = str_replace('/', '-', $value);
        $timestamp = strtotime($new_value);
        return Carbon::createFromTimestamp($timestamp);
    }
    );
}

Et voilà, problema risolto.