Chapter Casts dan Attribute
memberi kita konversi siap pakai: integer, boolean, datetime, encrypted:integer. Masalahnya, tipe data domain sering lebih kaya dari yang bisa dijelaskan satu kata. Harga rupiah paling aman disimpan sebagai integer sen (1850 berarti Rp18,50) agar bebas galat pembulatan, tetapi kode aplikasi ingin membacanya sebagai objek Money yang punya method add() dan formatRupiah() — bukan integer telanjang. Custom cast menjadi jembatan dua arah di antara keduanya: database tetap menerima angka, aplikasi menerima objek. Di chapter ini kita membangun pasangan MoneyCast + value object Money, lalu cast satu arah untuk kolom rahasia.
Kontrak CastsAttributes #
Semua cast class mengikuti satu kontrak dari dokumentasi eloquent-mutators
: interface CastsAttributes dengan dua method. Method get() mengubah nilai mentah database menjadi tipe yang enak dibaca aplikasi; method set() mengubah nilai aplikasi menjadi kolom yang siap disimpan:
<?php
namespace App\Casts;
use App\Support\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class MoneyCast implements CastsAttributes
{
public function __construct(protected int $scale = 2)
{
}
public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
return $value === null ? null : Money::fromCents((int) $value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return match (true) {
$value instanceof Money => [$key => $value->cents],
is_numeric($value) => [$key => (int) round(((float) $value) * (10 ** $this->scale))],
$value === null => [$key => null],
default => throw new InvalidArgumentException(
"Atribut {$key} harus berupa Money, angka, atau null."
),
};
}
}Perhatikan bentuk nilai keluaran set(): bukan nilai tunggal, melainkan array [$key => $nilai]. Format ini memungkinkan satu cast menulis ke beberapa kolom sekaligus jika dibutuhkan. Constructor menerima $scale — kita akan lihat cara mengisinya lewat sintaks parameter cast.
Value Object sebagai Pasangan Cast #
Nilai yang dikembalikan get() sebaiknya berupa value object: kelas kecil, immutable, tanpa efek samping. Money menyimpan sen sebagai satu-satunya state:
<?php
namespace App\Support;
final class Money
{
public function __construct(public readonly int $cents)
{
}
public static function fromCents(int $cents): self
{
return new self($cents);
}
public static function fromDecimal(int|float|string $amount): self
{
return new self((int) round(((float) $amount) * 100));
}
public function add(self $other): self
{
return new self($this->cents + $other->cents);
}
public function toDecimal(): float
{
return $this->cents / 100;
}
public function formatRupiah(): string
{
return 'Rp'.number_format($this->toDecimal(), 2, ',', '.');
}
}Karena add() mengembalikan instance baru alih-alih mengubah cents (yang ber -readonly), setiap operasi aritmetika menghasilkan objek baru — tidak ada satu tempat pun yang bisa mengubah harga secara diam-diam. Model Product (tabel products berisi name dan price) tinggal memasang cast-nya:
<?php
// app/Models/Product.php — bagian inti
protected function casts(): array
{
return [
'price' => AsMoney::using(),
];
}Hasil kerja samanya terlihat pada satu putaran penuh — simpan, baca, dan cek isi mentah database:
<?php
$product = Product::create(['name' => 'Kopi Toraja 200g', 'price' => Money::fromDecimal('18.5')]);
echo 'tipe di memori: '.get_class($product->price).PHP_EOL;
echo 'cents: '.$product->price->cents.PHP_EOL;
echo 'format: '.$product->price->formatRupiah().PHP_EOL;
$row = DB::table('products')->where('id', $product->id)->first();
echo 'kolom price di database: '.$row->price.PHP_EOL;Output yang Dihasilkan:
tipe di memori: App\Support\Money
cents: 1850
format: Rp18,50
kolom price di database: 1850Castable: Parameter dan Sintaks Fleksibel #
Baris AsMoney::using() di model tadi bukan sihir — AsMoney adalah class Castable. Kontraknya sederhana: satu method statis castUsing() yang memutuskan cast mana yang dipakai:
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class AsMoney implements Castable
{
public static function castUsing(array $arguments): CastsAttributes
{
return new MoneyCast(...array_map('intval', $arguments ?: [2]));
}
public static function using(int $scale = 2): string
{
return static::class.':'.$scale;
}
}Sintaks Class:parameter dikenali Eloquent: teks setelah titik dua dipecah koma lalu dikirim ke constructor. AsMoney::using() menghasilkan string App\Casts\AsMoney:2, sama dengan menulis manual 'price' => AsMoney::class.':2'. Parameter $scale dipakai set() ketika menerima angka mentah — Product::create(['price' => 25.5]) menyimpan 2550, bukan 255,000 hasil float.
Tiga Arah Kerja Cast #
Cast tidak selalu dua arah. Tiga pola yang perlu kita kenali:
| Pola | get() | set() | Contoh |
|---|---|---|---|
| Dua arah | mengubah ke objek | mengubah ke kolom | MoneyCast |
| Inbound-only | tidak dipanggil | mengubah nilai | Hashed |
| Read-only | membungkus nilai | menyimpan apa adanya | membungkus string ke HtmlString |
Pola inbound-only memakai kontrak CastsInboundAttributes — hanya set(). Contoh klasiknya kolom token: plaintext masuk, hash keluar:
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash as HashFacade;
class Hashed implements CastsInboundAttributes
{
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [$key => $value === null ? null : HashFacade::make($value)];
}
}Model ApiKey memasangnya pada kolom token. Pembuktian bahwa plaintext tidak pernah menyentuh database:
<?php
$apikey = ApiKey::create(['name' => 'budi-mobile', 'token' => 'rahasia-budi']);
$raw = DB::table('api_keys')->where('id', $apikey->id)->first();
echo 'prefix token di database: '.substr($raw->token, 0, 7).'... (bcrypt, 60 karakter)'.PHP_EOL;
echo 'baca ulang tetap hash: '.($apikey->token === $raw->token ? 'ya' : 'tidak').PHP_EOL;Output yang Dihasilkan:
prefix token di database: $2y$12$... (bcrypt, 60 karakter)
baca ulang tetap hash: yaNilai yang dibaca kembali tetap hash — get() memang tidak pernah dipanggil, sesuai kontraknya.
Menguji Cast #
Test cast memeriksa tiga hal: nilai di database (format mentah), nilai di memori (tipe dan angka), dan penolakan input yang salah. Berikut MoneyCastTest yang melakukannya sekaligus:
Output yang Dihasilkan:
PASS Tests\Feature\MoneyCastTest
✓ roundtrip dari desimal ke database dan kembali 0.14s
✓ set dengan angka mentah mengikuti scale parameter 0.02s
✓ set dengan tipe salah melempar exception 0.01s
Tests: 3 passed (8 assertions)
Duration: 0.24sTest pertama adalah inti bab ini: satu assertDatabaseHas('products', ['price' => 1850]) membuktikan format penyimpanan, sementara assertInstanceOf(Money::class, ...) membuktikan tipe bacaan. Ketika keduanya hijau, kita tahu konversi dua arah bekerja penuh — dan kode domain bekerja dengan Money, bukan angka telanjang.
Cast mengubah cara model membaca satu kolom. Bab berikutnya naik satu tingkat: mengubah cara kita menulis query — memindahkan kondisi domain ke custom Eloquent builder agar Post::popular() menjadi bahasa resmi aplikasi.