Custom Pivot Models

A pivot row with behavior deserves a class. It extends Relations\Pivot (MorphPivot for polymorphic pivots), both definitions add ->using(OrderItem::class), and accessors, casts and model events then work on pivot rows:

app/Models/OrderItem.php: a pivot that prices itselfPHP
class OrderItem extends Pivot
{
    protected $table = 'order_items';
    protected function lineTotal(): Attribute
    {
        return Attribute::get(fn () => bcmul($this->unit_price, (string) $this->quantity, 2));
    }
    protected static function booted(): void
    {
        static::creating(fn (OrderItem $item) =>
            $item->unit_price ??= Product::findOrFail($item->product_id)->price);
    }
}
Attaching without a price and letting the pivot fill it inSQL
use App\Models\Order;
$order = Order::find(3);
$order->products()->attach(7, ['quantity' => 2]);
foreach ($order->products()->get() as $p) {
    echo "{$p->sku}: {$p->pivot->quantity} x {$p->pivot->unit_price} = ",
        $p->pivot->line_total, "\n";
}
Output
-- select * from `orders` where `orders`.`id` = 3 limit 1
-- select * from `products` where `products`.`id` = 7 limit 1
-- insert into `order_items` (`product_id`, `order_id`, `quantity`, `unit_price`) values (7, 3,
    2, '12.50')
...
BK-LAR-01: 1 x 49.00 = 49.00
AC-MUG-01: 2 x 12.50 = 25.00

With using(), attach() saves each row through the class, so creating fired and froze the mug's current price into the order. $table is needed because a pivot class guesses a singular name. Pivots are non-incrementing; one with its own id declares #[Table(incrementing: true)]. bcmul() keeps money in decimal strings (BCMath).