Create order action

E-commerce admin panels are a great use case for custom actions. They're notoriously difficult to use for non-developers when they're built using tools that rely purely on your database schema.

For example, it's very common for the orders table to have only one column. An id. Everything else that an order has is in child relations.

You'd first need to create an order with no data and then start attaching resources, e.g. customers, addresses, order products, payment methods, etc.

One way to store payment/shipping methods is to create an order_fees table and store them as a special form of order fees. This works well when your application has more — conditional — fees, such as for payment methods, discounts (negative fees), special packaging, etc.

You wouldn't want to show the user an "Order Fees" relation, you'd want to show him an understandable UI for the delivery method, payment method, additional fees, and discounts.

And a final pain — you don't want to use simple many-to-many relations. You need to store extra columns on OrderProducts. For example, what happens if the product's name/price/tax rate/... are updated? The order data must not change. It should only change when you're editing the order, directly.

So, in short:

  • we want to select the customer, address, payment methods, order products on the Create Order page
  • we want a custom UI for all of those things
  • we want custom logic for creating order products

The absolutely most straightforward way to do this is to create a custom screen instead of trying to customize how fields display on create, get stored to the database, etc.

So, we'll do exactly that. (In a simplified way, the code below doesn't implement all the complexities mentioned above, but it uses a workflow that lets you do that.)

Resource

GitHub link

In the resource, we specify that we have a custom component for the create action.

public static function customActions(): string
{
    return [
        'create' => CreateOrderAction::class,
    ];
}

Action class

GitHub link

The action is a completely custom Livewire component:

<?php

namespace App\Lean\Actions;

use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderProduct;
use App\Models\Product;
use Illuminate\Database\Eloquent\Collection;
use Lean\Livewire\Actions\LeanAction;

class CreateOrderAction extends LeanAction
{
    public Order $order;
    public Collection $availableProducts;
    public array $products = [];

    public $rules = [
        'order.customer_id' => 'required|exists:customers,id',
        'products.*.product_id' => 'required|exists:products,id',
        'products.*.quantity' => 'required|numeric|min:1',
    ];

    public function mount()
    {
        $this->order = new Order;
        $this->order->customer_id = $this->customers->first()->id;

        $this->availableProducts = Product::all();

        $this->addProduct();
    }

    public function addProduct()
    {
        $this->products[] = [
            'product_id' => $this->availableProducts->first()->id,
            'quantity' => 1,
        ];
    }

    public function getProductTotal($product)
    {
        return $this->availableProducts->find($product['product_id'])->price * $product['quantity'];
    }

    public function removeProduct(int $id)
    {
        if (array_key_exists($id, $this->products)) {
            unset($this->products[$id]);
        }

        $this->products = array_values($this->products);
    }

    public function submit()
    {
        $this->validate();

        $this->order->save();

        $this->order->products()->createMany($this->products);

        $this->notifyOnNextPage('Order created.');

        return redirect(route('lean.resource.show', ['resource' => 'orders', 'id' => $this->order->id]));
    }

    public function getCustomersProperty()
    {
        return Customer::cursor();
    }

    public function render()
    {
        return view('lean.actions.create_order');
    }
}

In summary: We store the available products in a Collection, create an empty Order model, and keep track of the selected products (and the customer) in the component's data. Then there are some methods for adding products, removing products, etc. All of the data gets validated, and the submit() logic is a completely custom method that does what we want, can cause any side effects we want, and returns a response.

Blade view

GitHub link

<div>
    <h1 class="text-3xl font-medium">Create order</h1>
    <label class="mt-4 block">
        Customer
        <select class="form-select" wire:model="order.customer_id">
            @foreach($this->customers as $customer)
                <option value="{{ $customer->id }}">{{ $customer->name }} ({{ $customer->email }})</option>
            @endforeach
        </select>
        @error('order.customer_id')
            <div class="mt-1 text-red-500 text-sm">
                <p>{{ $message }}</p>
            </div>
        @enderror
    </label>

    <div class="flex flex-col mt-8">
        <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
            <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
                <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
                    <table class="min-w-full divide-y divide-gray-200">
                        <thead>
                            <tr>
                                <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                                    Product
                                </th>
                                <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                                    Quantity
                                </th>
                                <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
                                    Total
                                </th>
                                <th class="px-6 py-3 bg-gray-50"></th>
                            </tr>
                        </thead>
                    <tbody>
                        @foreach($products as $index => $product)
                            @if($loop->odd)
                                {{-- Odd row --}}
                                <tr class="bg-white">
                            @else
                                {{-- Even row --}}
                                <tr class="bg-gray-50">
                            @endif
                                <td class="px-6 py-4 whitespace-no-wrap text-base leading-5 font-medium text-gray-900">
                                    <select class="form-select" wire:model="products.{{ $index }}.product_id">
                                        @foreach($availableProducts as $ap)
                                            <option value="{{ $ap->id }}">{{ $ap->name }}</option>
                                        @endforeach
                                    </select>
                                </td>
                                <td class="px-6 py-4 whitespace-no-wrap text-base leading-5 text-gray-800">
                                    <input type="number" class="form-input" wire:model="products.{{ $index }}.quantity" step="1" min="1">
                                </td>
                                <td class="px-6 py-4 whitespace-no-wrap text-base font-medium leading-5 text-gray-900">
                                    ${{ $this->getProductTotal($product) }}
                                </td>
                                <td class="px-6 py-4 whitespace-no-wrap text-right text-base leading-5 font-medium">
                                    <x-lean::button design="danger" wire:click="removeProduct({{ $index }})">
                                        Remove
                                    </x-lean::button>
                                </td>
                            </tr>
                    @endforeach
                    </tbody>
                </table>
            </div>

            <div class="flex justify-end mt-4">
                <x-lean::button design="secondary" wire:click="addProduct">
                    Add Product
                </x-lean::button>

                <div class="ml-2">
                    <x-lean::button wire:click="submit">
                        Create Order
                    </x-lean::button>
                </div>
            </div>
        </div>
    </div>
</div>

The view is as straightforward as it gets. Just basic Blade variables, loops, and a few Livewire calls.

And it all just works.