Skip to content

Shopping Cart

This recipe demonstrates building a reactive shopping cart with add/remove items, quantity adjustment, and automatically calculated totals using Datastar's computed signals.

What We're Building

A shopping cart with:

  • Adjust quantities
  • Remove items
  • Automatic subtotal, tax, and total calculations
  • Empty cart state

Complete Implementation

Blade Template

Create resources/views/cart/index.blade.php:

blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Shopping Cart</title>
    @hyper
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
    <div class="max-w-6xl mx-auto px-4 py-8" @signals(['total' => $carts->sum(fn($cart) => $cart->price * $cart->quantity)])>

        <h1 class="text-3xl font-bold mb-8">Shopping Cart</h1>

        @fragment('cart-total')
        <div id="cart-total" class="bg-blue-600 text-white p-4 rounded-lg mb-6 text-xl font-bold">
            Total: $<span data-text="$total.toFixed(2)"></span>
        </div>
        @endfragment

        @fragment('cart-items')
        <div id="cart-items">
            @if($carts->isEmpty())

                <div class="text-center py-12">
                    <p class="text-gray-500 text-xl">Your cart is empty!</p>
                </div>
            @else

                <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
                    @foreach ($carts as $cart)
                        <div class="bg-white rounded-lg shadow p-4"
                             @signals(["editingItem_{$cart->id}" => false, "tempQuantity_{$cart->id}" => $cart->quantity])>

                            <img src="{{ $cart->image }}"
                                 alt="{{ $cart->name }}"
                                 class="w-full h-48 object-cover rounded mb-3">

                            <h3 class="font-bold text-lg mb-2">{{ $cart->name }}</h3>

                            <p class="text-gray-600 text-sm mb-2">{{ $cart->description }}</p>

                            <p class="text-gray-800 mb-3">
                                Unit Price: <span class="font-semibold">${{ $cart->price }}</span>
                            </p>

                            <div class="mb-3">
                                <label class="block text-sm font-medium mb-1">Quantity</label>
                                <div class="flex items-center gap-2">

                                    <button
                                        data-on:click="$editingItem_{{ $cart->id }} = true; $tempQuantity_{{ $cart->id }} = Math.max(1, $tempQuantity_{{ $cart->id }} - 1)"
                                        class="w-8 h-8 bg-red-500 text-white rounded hover:bg-red-600">

                                    </button>

                                    <span class="w-16 text-center font-semibold text-lg" data-text="$tempQuantity_{{ $cart->id }}">
                                        {{ $cart->quantity }}
                                    </span>

                                    <button
                                        data-on:click="$editingItem_{{ $cart->id }} = true; $tempQuantity_{{ $cart->id }} = $tempQuantity_{{ $cart->id }} + 1"
                                        class="w-8 h-8 bg-green-500 text-white rounded hover:bg-green-600">
                                        +
                                    </button>

                                    <button
                                        data-show="$editingItem_{{ $cart->id }}"
                                        data-on:click="@putx('/cart/{{ $cart->id }}/update')"
                                        class="w-8 h-8 bg-green-600 text-white rounded hover:bg-green-700 flex items-center justify-center">

                                    </button>
                                </div>
                            </div>

                            <p class="text-lg font-bold text-blue-600">
                                Total: ${{ number_format($cart->price * $cart->quantity, 2) }}
                            </p>

                            <button
                                data-on:click="@deletex('/cart/{{ $cart->id }}')"
                                class="mt-3 w-full bg-red-500 text-white py-2 rounded hover:bg-red-600">
                                Remove Item
                            </button>
                        </div>
                    @endforeach
                </div>
            @endif
        </div>
        @endfragment
    </div>
</body>
</html>

Controller

Create app/Http/Controllers/CartController.php:

php
<?php

namespace App\Http\Controllers;

use App\Models\CartItem;
use Illuminate\Http\Request;

class CartController extends Controller
{
    public function index()
    {
        $carts = CartItem::all();

        return view('cart.index', compact('carts'));
    }

    public function update(Request $request, $id)
    {
        $item = CartItem::findOrFail($id);

        $quantity = signals("tempQuantity_{$id}", 1);

        if ($quantity < 1) {
            $quantity = 1;
        }

        $item->quantity = $quantity;
        $item->save();

        $carts = CartItem::all();
        $total = $carts->sum(fn($cart) => $cart->price * $cart->quantity);

        return hyper()
            ->signals([
                'editingItem_' . $id => false,
                'tempQuantity_' . $id => $quantity,
                'total' => $total
            ])
            ->fragment('cart.index', 'cart-items', compact('carts'))
            ->fragment('cart.index', 'cart-total', compact('carts'));
    }

    public function decrease($id)
    {
        $item = CartItem::findOrFail($id);

        if ($item->quantity > 1) {
            $item->decrement('quantity');
        }

        $carts = CartItem::all();
        $total = $carts->sum(fn($cart) => $cart->price * $cart->quantity);

        return hyper()
            ->signals(['total' => $total])
            ->fragment('cart.index', 'cart-items', compact('carts'))
            ->fragment('cart.index', 'cart-total', compact('carts'));
    }

    public function destroy($id)
    {
        $item = CartItem::findOrFail($id);
        $item->delete();

        $carts = CartItem::all();
        $total = $carts->sum(fn($cart) => $cart->price * $cart->quantity);

        return hyper()
            ->signals(['total' => $total])
            ->fragment('cart.index', 'cart-items', compact('carts'))
            ->fragment('cart.index', 'cart-total', compact('carts'));
    }
}

Routing

In your web.php

php
use App\Http\Controllers\CartController;

Route::get('/cart', [CartController::class, 'index'])->name('cart.index');
Route::put('/cart/{id}/update', [CartController::class, 'update'])->name('cart.update');
Route::put('/cart/{id}/decrease', [CartController::class, 'decrease'])->name('cart.decrease');
Route::delete('/cart/{id}', [CartController::class, 'destroy'])->name('cart.destroy');

How It Works

data-on:click (Datastar)

Attaches click event listeners that execute JavaScript expressions:

blade
<button data-on:click="$editingItem_{{ $cart->id }} = true; $tempQuantity_{{ $cart->id }} = Math.max(1, $tempQuantity_{{ $cart->id }} - 1)">

Breakdown:

  • data-on:click - Datastar attribute for handling click events
  • $editingItem - Updates signal to show the save button
  • $tempQuantity - Decreases quantity but ensures minimum of 1

data-show (Datastar)

Conditionally shows/hides elements using CSS display property:

blade
<button data-show="$editingItem_{{ $cart->id }}" data-on:click="@putx('/cart/{{ $cart->id }}/update')">

</button>

How it works:

  • When $editingItem is true: Element is visible
  • When false: Sets display: none on the element
  • Element stays in the DOM - only visibility changes
  • Fast to toggle, preserves element state

@fragment Directive (Hyper)

The @fragment directive defines reusable sections of your Blade template:

blade
@fragment('cart-items')
<div id="cart-items">
    <!-- content -->
</div>
@endfragment

@fragment('cart-total')
<div id="cart-total">
    Total: $<span data-text="$total.toFixed(2)"></span>
</div>
@endfragment

Purpose:

  • Marks sections that can be independently updated
  • Keeps related HTML in one view file
  • Enables surgical DOM updates without re-rendering the entire page

Why multiple fragments?

php
->fragment('cart.index', 'cart-items', compact('carts'))
->fragment('cart.index', 'cart-total', compact('carts'))

This updates both the cart items list AND the total display in a single response. Hyper sends one SSE event with multiple fragment updates, ensuring atomic UI updates.

Key Takeaways

1. Fragment Updates:

  • Extract specific sections from Blade views
  • Render only what changed with new data
  • Send via SSE for intelligent DOM morphing
  • Multiple fragments can update atomically in one response

2. Optimistic UI Updates:

  • Client-side signals update instantly for immediate feedback
  • Server validates and persists changes
  • Fragment updates ensure UI stays in sync with database state

This shopping cart demonstrates the power of combining Datastar's reactive frontend with Hyper's Laravel integration. You get instant UI feedback from client-side signal updates while maintaining server-side validation and persistence. The fragment system enables surgical DOM updates—only the cart items and total recalculate when needed—without full page reloads or complex state management. CSRF protection happens automatically, and the entire data flow from button click to database update to UI refresh requires minimal code. This pattern scales to complex e-commerce applications while keeping your codebase maintainable and your UX snappy.