مقدمة
كلنا بنكتب كود زي ده كتير:
// تحويل المبلغ من قرش لجنيه في كل مكان في الكود
$price = $product->price / 100;
$formatted = number_format($price, 2) . ' EGP';
ده بيتكرر في عشرين مكان في التطبيق. أي تعديل في طريقة العرض — هتعدّل عشرين مكان. الـ Custom Casts بتحل المشكلة دي من جذرها بإنها تحوّل الحقل لكائن PHP كامل عنده منطقه الخاص.
01. ما هو الـ Custom Cast؟
الـ Custom Cast في Laravel هو كلاس بيخبر الـ Eloquent Model: "لما تجيب الحقل ده من الداتابيز، مش هترجعه خام — هتحوّله لكائن PHP". ولما تحفظه، الكائن ده هيتحوّل تلقائياً للقيمة المناسبة للتخزين.
الميزة دي موجودة من Laravel 8 وكتير من المطورين لحد دلوقتي مش بيستخدموها ومش عارفينها.
02. مثال عملي: Money Value Object
هنبني نظام أسعار كامل. الأسعار محفوظة في الداتابيز كـ integer (قروش) وعايزين نتعامل معاها كـ object.
خطوة 1: إنشاء الـ Value Object
// app/ValueObjects/Money.php
namespace App\ValueObjects;
final readonly class Money
{
public function __construct(
public readonly int $amount, // مخزّن بالقروش في الداتابيز
public readonly string $currency = 'EGP',
) {}
/** القيمة بالجنيه (للعرض) */
public function value(): float
{
return $this->amount / 100;
}
/** عرض منسّق جاهز للواجهة */
public function formatted(): string
{
return number_format($this->value(), 2) . ' ' . $this->currency;
}
/** جمع مبلغين */
public function add(Money $other): static
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('لا يمكن جمع عملتين مختلفتين.');
}
return new static($this->amount + $other->amount, $this->currency);
}
/** تطبيق خصم بالنسبة المئوية */
public function discountBy(float $percentage): static
{
$discounted = (int) round($this->amount * (1 - $percentage / 100));
return new static($discounted, $this->currency);
}
/** مقارنة مبلغين */
public function isGreaterThan(Money $other): bool
{
return $this->amount > $other->amount;
}
/** إنشاء من قيمة float مباشرة */
public static function fromFloat(float $value, string $currency = 'EGP'): static
{
return new static((int) round($value * 100), $currency);
}
}
خطوة 2: إنشاء الـ Cast Class
php artisan make:cast MoneyCast
// app/Casts/MoneyCast.php
namespace App\Casts;
use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class MoneyCast implements CastsAttributes
{
public function __construct(
private readonly string $currency = 'EGP'
) {}
/**
* من الداتابيز → كائن Money
*/
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
return new Money(
amount: (int) $value,
currency: $this->currency,
);
}
/**
* كائن Money → للداتابيز (integer)
*/
public function set(Model $model, string $key, mixed $value, array $attributes): int
{
return $value instanceof Money
? $value->amount
: (int) $value;
}
}
خطوة 3: ربطه بالـ Model
// app/Models/Product.php
namespace App\Models;
use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = ['name', 'price', 'original_price'];
protected function casts(): array
{
return [
// تمرير العملة كـ parameter للـ Cast
'price' => MoneyCast::class . ':EGP',
'original_price' => MoneyCast::class . ':EGP',
];
}
}
خطوة 4: استخدامه في الكود
قبل Custom Cast — الكود المكرر:
// ❌ الطريقة القديمة — متكررة وغير آمنة
$product = Product::find(1);
$price = $product->price / 100;
$formatted = number_format($price, 2) . ' EGP';
// في مكان تاني في الكود نفس الكلام ...
$discounted = $product->price * 0.9 / 100;
بعد Custom Cast — كود نظيف وذكي:
// ✅ الطريقة الجديدة — نظيفة وآمنة
$product = Product::find(1);
// $product->price الآن كائن Money وليس integer!
$product->price->formatted(); // "1,500.00 EGP"
$product->price->value(); // 1500.00
$product->price->discountBy(10)->formatted(); // "1,350.00 EGP"
// جمع مبلغين
$total = $product->price->add($product->original_price);
$total->formatted(); // "3,000.00 EGP"
// مقارنة
if ($product->price->isGreaterThan(Money::fromFloat(1000))) {
// المنتج أغلى من 1000 جنيه
}
// الحفظ في الداتابيز يشتغل بشكل طبيعي
$product->price = Money::fromFloat(2000.00);
$product->save(); // يُخزّن 200000 في الداتابيز تلقائياً
03. مثال ثانٍ: Coordinate Cast
بدل ما تخزّن الـ latitude والـ longitude في عمودين منفصلين وتحسب المسافات يدوياً في كل مكان — اعمل كائن واحد بيتكلم عن الإحداثيات.
// app/ValueObjects/Coordinate.php
namespace App\ValueObjects;
final readonly class Coordinate
{
public function __construct(
public readonly float $latitude,
public readonly float $longitude,
) {}
/** حساب المسافة بالكيلومتر (Haversine Formula) */
public function distanceTo(Coordinate $other): float
{
$earthRadius = 6371;
$latDiff = deg2rad($other->latitude - $this->latitude);
$lonDiff = deg2rad($other->longitude - $this->longitude);
$a = sin($latDiff / 2) ** 2
+ cos(deg2rad($this->latitude))
* cos(deg2rad($other->latitude))
* sin($lonDiff / 2) ** 2;
return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
}
/** تحويل لـ JSON للتخزين */
public function toJson(): string
{
return json_encode([
'lat' => $this->latitude,
'lng' => $this->longitude,
]);
}
public static function fromJson(string $json): static
{
$data = json_decode($json, true);
return new static($data['lat'], $data['lng']);
}
}
// app/Casts/CoordinateCast.php
namespace App\Casts;
use App\ValueObjects\Coordinate;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class CoordinateCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): Coordinate
{
return Coordinate::fromJson($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return $value instanceof Coordinate
? $value->toJson()
: $value;
}
}
// app/Models/Branch.php
class Branch extends Model
{
protected function casts(): array
{
return [
'location' => CoordinateCast::class,
];
}
}
// الاستخدام:
$branch = Branch::find(1);
$customer = new Coordinate(30.0444, 31.2357); // إحداثيات القاهرة
$distance = $branch->location->distanceTo($customer);
// $distance = 2.4 كيلومتر — بدون أي كود خارج الكائن
04. Inbound Cast — للتحقق فقط عند الحفظ
أحياناً مش محتاج تحوّل القيمة عند القراءة، بس عايز تتحقق منها وتعالجها عند الحفظ فقط. في الحالة دي تستخدم Inbound Cast.
// app/Casts/Uppercase.php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Database\Eloquent\Model;
class Uppercase implements CastsInboundAttributes
{
/** يُشغَّل عند الحفظ فقط — يحوّل النص لـ uppercase */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return strtoupper($value);
}
}
// الاستخدام في الـ Model:
protected function casts(): array
{
return [
'country_code' => Uppercase::class,
];
}
// نتيجة:
$user->country_code = 'eg';
$user->save();
// يُخزَّن 'EG' في الداتابيز تلقائياً
05. اختبار الـ Custom Casts
لأن كل Cast هو كلاس مستقل، الاختبار بيبقى سهل وسريع جداً:
namespace Tests\Unit\ValueObjects;
use App\ValueObjects\Money;
use PHPUnit\Framework\TestCase;
class MoneyTest extends TestCase
{
public function test_formatted_output_is_correct(): void
{
$money = new Money(150000, 'EGP');
$this->assertEquals('1,500.00 EGP', $money->formatted());
}
public function test_discount_is_applied_correctly(): void
{
$money = new Money(100000); // 1000 جنيه
$discounted = $money->discountBy(10);
$this->assertEquals(90000, $discounted->amount);
$this->assertEquals('900.00 EGP', $discounted->formatted());
}
public function test_adding_different_currencies_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
(new Money(1000, 'EGP'))->add(new Money(1000, 'USD'));
}
public function test_from_float_converts_correctly(): void
{
$money = Money::fromFloat(99.99);
$this->assertEquals(9999, $money->amount);
}
}
06. هيكل المجلدات الموصى به
app/
├── Casts/
│ ├── MoneyCast.php
│ ├── CoordinateCast.php
│ └── Uppercase.php
├── ValueObjects/
│ ├── Money.php
│ └── Coordinate.php
└── Models/
├── Product.php ← uses MoneyCast
└── Branch.php ← uses CoordinateCast
الخلاصة
الـ Custom Casts مع Value Objects هما من أقوى أدوات تصميم الكود في Laravel. بيخليك تتخلص نهائياً من تكرار منطق التحويل في كل مكان، وكل حقل في الـ Model بيبقى عنده "دماغه" الخاص — يعرف يعرض نفسه، يعرف يتعامل مع نفسه، ويعرف يتحقق من نفسه.