I’ve just completed my first tiny product DoubleShot, a quick and easy way to create and share ‘before’ and ‘after’ photos to social media.

The final piece was to get payments working so that people could buy the app and get access.

It seems that the favoured way to do this is with Laravel Cashier - have the customer create an account first, then subscribe that User model to a subscription plan. But I couldn’t find any examples of achieving this with one-time payments. I don’t want my customers to have to create an account first and then buy access!

Have you ever wanted to flex on the group chat and show your progress through a project? Show your family how quickly the kids are growing up?

Create a social media-ready “before” and “after” photo with DoubleShot!

Why one-time?

I’m a huge believer in one-time payments.

Executing them properly requires a huge amount of discipline - you have to stay lean and efficient, and you have to massively over-deliver on value. But when you do it right, you’re an amazing value proposition to customers and you leave your competitors wondering ‘.. but how are they doing this?’.

Let’s go!

I chose LemonSqueezy as my payment provider, because I wanted someone else to be the Merchant of Record and automatically handle digital sales tax for me.

They support passing variables through to your success URL. This allows me to receive an asynchronous webhook and allow the user to create their account as soon as I’ve had confirmation of their order.

I configured my success URL in LemonSqueezy as follows:

doubleshot.app/success?order_id=[order_id]&customer_email=[email]&customer_name=[name]

Then I’ve set up a webhook in LemonSqueezy which receives the order_created and order_refunded events:

doubleshot.app/api/order_webhook

To get things working, let’s establish a couple of routes in our ./routes/web.php file:


// Override Filament's default registration page with our own custom one and its middleware
Route::get('/admin/register', Register::class)->name('filament.app.auth.register')->middleware('check.order');

// Our temporary success page
Route::get('/success', 'success')->name('success');

// And our incoming webhook endpoint
Route::post('/api/order_webhook', 'webhook')->name('api.webhooks.order');

The check.order middleware applied to the overriden registration route is my CheckSuccessfulPurchase middleware. This checks for an incoming order ID in the query string, looks up its status in the database, and will redirect to the login page if the user has already registered. It prevents someone from going to /register and being able to create their account without a valid order ID.


<?php

namespace App\Http\Middleware;

use Closure;
use App\Models\Order;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckSuccessfulPurchase
{
    public function handle(Request $request, Closure $next): Response
    {
        // Check if the query string parameter exists
        if (!$request->has('order_id')) {
            return redirect(route('index')); // Redirect or abort if parameter is missing
        }

        $order_id = $request->query('order_id');

        // Does the order exist in the database?
        $validOrder = Order::where('remote_order_id', $order_id)->first();

        if(!$validOrder) {
            return redirect(route('success', ['order_id' => $order_id,])); // Redirect or abort if the order is invalid
        }

        // Is the order already associated with a user object? Redirect to login
        if(!empty($validOrder->user_id)) {
            return redirect(route('login'));
        }

        return $next($request);
    }
}

Next, I’ve added the following two methods to my controller. The success() method is triggered when the customer is redirected back to your application. It will check the incoming order ID and await the webhook.

Note: You’ll want to create your success_pending view to render a loading spinner or similar, and refresh the page every few seconds so that it re-checks the status and redirects them once it’s been confirmed.

The webhook() method receives the data from LemonSqueezy, verifies the signature, and stores the order.


public function success(Request $request) {
    $order_id = $request->input('order_id');
    $customer_email = $request->input('customer_email');
    $customer_name = $request->input('customer_name');

    // Check for presence of all values
    $validator = Validator::make([
        'order_id' => $order_id,
        'customer_email' => $customer_email,
        'customer_name' => $customer_name,
    ], [
        'order_id' => 'required|numeric',
        'customer_email' => 'required|email',
        'customer_name' => 'required|string|min:3',
    ]);

    if($validator->fails()) {
        $errors = $validator->errors();
        return response()->json(['message' => 'Validation failed', 'errors' => $errors], 400);
    }

    // Does this order already exist? If not, let's track it here
    $order = Order::firstOrCreate(['remote_order_id' => $order_id,], ['status' => OrderStatus::Pending, 'customer_name' => $customer_name, 'customer_email' => $customer_email,]);

    // Okay, is the order associated with an account? If so, go to login
    if(!empty($order->user_id)) {
        return redirect('login');
    }

    // Now switch on the status of the order
    switch($order->status) {
        case OrderStatus::Paid->value:
            return redirect()->route('filament.app.auth.register', ['order_id' => $order_id,]);
            break;
        case OrderStatus::Refunded->value:
            return response()->json(['message' => 'This order has been refunded.'], 400);
            break;
        case OrderStatus::Failed->value:
            return response()->json(['message' => 'Your payment was not successful.'], 400);
            break;
        default:
            // We're still waiting for the webhook to process, so show a loading spinner and refresh after N seconds
            return view('success_pending', ['customer_name' => $customer_name, 'customer_email' => $customer_email, 'order_id' => $order_id,]);
    }
}

public function webhook() {
    $secret = config('services.lemonsqueezy.webhook_secret');
    $payload = file_get_contents('php://input');
    $hash = hash_hmac('sha256', $payload, $secret);
    $signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
    
    if (!hash_equals($hash, $signature)) {
        return response()->json(['message' => 'Webhook signature does not match.',], 400);
    }

    $incoming = (is_string($payload) ? json_decode($payload, true) : $payload);

    switch($incoming['meta']['event_name']) {
        case 'order_created':
            $order = Order::updateOrCreate(['remote_order_id' => $incoming['data']['id'],], [
                'status' => OrderStatus::fromString($incoming['data']['attributes']['status']),
                'remote_identifier' => $incoming['data']['attributes']['identifier'],
                'customer_name' => $incoming['data']['attributes']['user_name'],
                'customer_email' => $incoming['data']['attributes']['user_email'],
                'remote_order_currency' => $incoming['data']['attributes']['currency'],
                'remote_order_total' => $incoming['data']['attributes']['total'],
                'remote_order_mode' => OrderMode::tryFrom(empty($incoming['meta']['test_mode']))->value,
            ]);

            return response()->json(['message' => 'Webhook processed.', 'order' => $order,], 200);
            break;

        case 'order_refunded':
            $order = Order::where('remote_order_id', $incoming['data']['id'])->firstOrFail();
            
            $originalStatus = OrderStatus::tryFrom($order->status);

            $newStatus = OrderStatus::fromString($incoming['data']['attributes']['status']);

            if($originalStatus === $newStatus) {
                return response()->json(['message' => 'Status already updated.',], 200);
            }

            $order->update([
                'status' => $newStatus,
            ]);

            break;
    }

    return response()->json(['message' => 'Webhook processed.'], 200);
}

I’ve then added a simple model to track my orders and their statuses, and to allow it to be associated with a user ID once the user gone through the registration process and created their account:


<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    use HasFactory;

    public function associate(User $user) : void {
        $this->update(['user_id' => $user->id,]);
    }
}

I’ve established a simple OrderObserver Observer to watch for when the order gets changed from paid to refunded, and disables the user. I’m using a status attribute on my User model to determine if someone should be able to log in or not - this could be expanded to support things like a customer who re-purchases again after a refund, but this is enough for my needs in this simple side project.


<?php

namespace App\Observers;

use Exception;
use App\Models\User;
use App\Models\Order;
use App\Enums\UserStatus;
use App\Enums\OrderStatus;
use Illuminate\Support\Facades\Log;

class OrderObserver
{
    /**
     * Handle the Order "updating" event.
     */
    public function updating(Order $order): void
    {
        if ($order->isDirty('status') && $order->getOriginal('status') !== OrderStatus::Refunded && $order->status === OrderStatus::Refunded) {
            Log::info('Order status changed to Refunded', ['order_id' => $order->id]);

            if(!empty($order->user_id)) {
                $user = User::where('id', $order->user_id)->firstOrFail();
                
                $user->update(['status' => UserStatus::Disabled->value,]);
            }
        }
    }
}

As you’ll recall from the route file, we’re replacing Filament’s default registration page. We’ll override it, and replace the mount() method to store the associated order, and the register() method to associate the order with the user once they’re in:


<?php

namespace App\Filament\Pages;

use App\Models\Order;
use Livewire\Attributes\Url;
use Filament\Facades\Filament;
use Illuminate\Auth\Events\Registered;
use Filament\Pages\Auth\Register as BaseRegister;
use Filament\Http\Responses\Auth\Contracts\RegistrationResponse;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;

class Register extends BaseRegister
{
    #[Url]
    public $order_id = '';

    public ?Order $order = null;

    public ?array $data = [];

    public function mount(): void
    {
        $this->order = Order::where('remote_order_id', $this->order_id)->firstOrFail();

        if (Filament::auth()->check()) {
            redirect()->intended(Filament::getUrl());
        }

        $this->callHook('beforeFill');

        $this->form->fill([
            'name' => $this->order->customer_name,
            'email' => $this->order->customer_email,
        ]);

        $this->callHook('afterFill');
    }

    public function register(): ?RegistrationResponse
    {
        try {
            $this->rateLimit(2);
        } catch (TooManyRequestsException $exception) {
            $this->getRateLimitedNotification($exception)?->send();

            return null;
        }

        $user = $this->wrapInDatabaseTransaction(function () {
            $this->callHook('beforeValidate');

            $data = $this->form->getState();

            $this->callHook('afterValidate');

            $data = $this->mutateFormDataBeforeRegister($data);

            $this->callHook('beforeRegister');

            $user = $this->handleRegistration($data);

            $this->form->model($user)->saveRelationships();

            $this->callHook('afterRegister');

            return $user;
        });

        $this->order->associate($user);

        event(new Registered($user));

        $this->sendEmailVerificationNotification($user);

        Filament::auth()->login($user);

        session()->regenerate();

        return app(RegistrationResponse::class);
    }
}

Before we can test this out, let’s create the migration which creates our orders table.

Note: I’ve already added the status attribute to my User model! If you’re copying this, you’ll need to do the same.


<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->timestamps();
            $table->foreignId('user_id')->nullable();
            $table->unsignedTinyInteger('status')->default(0)->index();
            $table->unsignedInteger('remote_order_id')->index();
            $table->string('remote_identifier')->nullable()->index();
            $table->string('customer_name')->nullable();
            $table->string('customer_email')->nullable();
            $table->string('remote_order_currency')->nullable();
            $table->unsignedInteger('remote_order_total')->nullable();
            $table->unsignedTinyInteger('remote_order_mode')->default(1)->index();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

And finally, we have three simple enum classes to map some of these integer values to something more readable.


<?php
namespace App\Enums;

enum OrderMode: int {
    case Test = 0;
    case Live = 1;
}

<?php
namespace App\Enums;

enum OrderStatus: int {
    case Pending = 0;
    case Failed = 1;
    case Paid = 2;
    case Refunded = 3;

    public static function fromString(string $status): ?self {
        return match (strtolower($status)) {
            'pending' => self::Pending,
            'failed' => self::Failed,
            'paid' => self::Paid,
            'refunded' => self::Refunded,
            default => null,
        };
    }
}

<?php
namespace App\Enums;

enum UserStatus: int {
    case Disabled = 0;
    case Active = 1;
}

Wrapping up

So there we have it! Customers can trigger their purchase through Lemon Squeezy. When they buy, they’ll be taken to a temporary holding page while we wait for Lemon Squeezy to notify us that their payment was successful.

Once that’s done, they’ll be redirected to the registration page to create their account and get access.

For more advanced cases, you’ll want to restrict by the related product ID (my store contains a single product!), expand the user status to allow for re-purchases and other scenarios, and use a better method for watching for the webhook to come through instead of refreshing that holding page - probably Laravel Broadcasts.