# Recipe: Laravel

For Laravel 10 or later (PHP 8.1+), with Blade views. This file has the code; [the playbook](https://checkouts.md/playbook.md) has the reasons, every error code and what to tell the person. Follow both.

| | |
|---|---|
| Server code runs in | Controllers, with Laravel's HTTP client |
| Database | The project's database through Eloquent (PostgreSQL, MySQL or SQLite) |
| Webhook path | `/webhooks/railbed` (exempt from CSRF) |
| Connect | `npx checkouts.md login` (Test into `.env`, Live into `.env.production`) |

## What this adds

| File | What it does |
|---|---|
| `database/migrations/2026_10_14_000000_create_railbed_tables.php` | The `orders` and `railbed_events` tables |
| `config/products.php` | What's for sale, priced on the server |
| `config/services.php` | Reads the Railbed settings from the environment |
| `app/Models/Order.php` | The order model |
| `app/Services/Railbed.php`, `app/Services/RailbedException.php` | The API client and signature verification |
| `app/Http/Controllers/CheckoutController.php` | Creates the order and its checkout session; the success page |
| `app/Http/Controllers/RailbedWebhookController.php` | Verifies, re-reads the payment, applies it once |
| `routes/web.php`, `bootstrap/app.php` | The three routes, and the webhook's CSRF exemption |
| `resources/views/checkout/success.blade.php`, the Pay button | The views |
| `CHECKOUTS.md` | How this project takes payments |

If the project already has an `orders` table or an `Order` model, name these `checkout_orders` and `CheckoutOrder` (or add the columns to the existing table).

## Environment

| Name | Development (`.env`, gitignored) | Production (the server's environment) |
|---|---|---|
| `RAILBED_SECRET_KEY` | `rb_test_…` | `rb_live_…` |
| `RAILBED_WEBHOOK_SECRET` | `whsec_…` of the Test endpoint | `whsec_…` of the Live endpoint |
| `APP_URL` | `http://localhost:8000` | `https://yourapp.com` |

Laravel's `.env` is already gitignored. Code reads the values through `config()`, so `php artisan config:cache` keeps working.

## 1. Connect

```bash
npx checkouts.md login
```

It adds `RAILBED_SECRET_KEY` to `.env` (keeping the rest of the file) and writes the Live key to `.env.production`, gitignored. Relay the link and code to the person and wait for the command to finish.

If the project deploys with Vapor (a `vapor.yml`), whose `vapor env:pull production` writes its own `.env.production`, connect with `npx checkouts.md login --live-to env:.env.railbed` instead, and give the Live webhook command in [step 6](#6-go-live) the same `--live-to`.

## 2. Database

`database/migrations/2026_10_14_000000_create_railbed_tables.php`:

```php
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->string('id')->primary();                 // order_…: sent as reference and in the Idempotency-Key
            $table->string('product_id');
            $table->string('amount');                        // "49.00"
            $table->string('currency', 3);
            $table->boolean('livemode');
            $table->string('status')->default('awaiting_payment');
            $table->string('payment_id')->nullable()->unique(); // pay_…
            $table->unsignedBigInteger('user_id')->nullable();
            $table->timestamp('paid_at')->nullable();
            $table->timestamps();
        });

        Schema::create('railbed_events', function (Blueprint $table) {
            $table->string('id')->primary();                 // evt_…
            $table->string('type');
            $table->string('payment_id')->nullable();
            $table->timestamp('received_at')->useCurrent();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('railbed_events');
        Schema::dropIfExists('orders');
    }
};
```

```bash
php artisan migrate
```

## 3. The code

`config/products.php`:

```php
<?php

// Prices live here, on the server, as decimal strings (R3). The browser only ever sends an id.
return [
    'pro' => ['name' => 'Pro plan', 'amount' => '49.00', 'currency' => 'USD'],
];
```

`config/services.php`, inside the returned array:

```php
    'railbed' => [
        'key' => env('RAILBED_SECRET_KEY'),
        'webhook_secret' => env('RAILBED_WEBHOOK_SECRET'),
        'api_base' => env('RAILBED_API_BASE', 'https://pay.railbed.io/v1'),
    ],
```

`app/Models/Order.php`:

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected $keyType = 'string';

    public $incrementing = false;

    protected $guarded = [];

    protected $casts = [
        'livemode' => 'boolean',
        'paid_at' => 'datetime',
    ];
}
```

`app/Services/RailbedException.php`:

```php
<?php

namespace App\Services;

use RuntimeException;

class RailbedException extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly string $errorCode, string $message)
    {
        parent::__construct($message);
    }
}
```

`app/Services/Railbed.php`:

```php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class Railbed
{
    /** Calls the Railbed API with the secret key. Server code only. */
    public function request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
    {
        $key = config('services.railbed.key');
        if (! $key) {
            throw new RuntimeException('RAILBED_SECRET_KEY is not set');
        }
        $client = Http::withToken($key)->acceptJson()->timeout(15);
        if ($idempotencyKey) {
            $client = $client->withHeaders(['Idempotency-Key' => $idempotencyKey]);
        }
        $url = rtrim(config('services.railbed.api_base'), '/').$path;
        $response = $method === 'GET' ? $client->get($url) : $client->post($url, $body ?? []);
        if ($response->successful()) {
            return $response->json();
        }
        throw new RailbedException(
            $response->status(),
            (string) $response->json('error.code', 'unknown'),
            (string) $response->json('error.message', 'Railbed answered '.$response->status()),
        );
    }

    /** True when this environment holds a Live key. */
    public static function isLive(): bool
    {
        return str_starts_with((string) config('services.railbed.key'), 'rb_live_');
    }

    /** Railbed-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, one or more v1 (two during a secret roll). */
    public static function verifySignature(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): bool
    {
        if (! preg_match('/^t=(\d{1,12})((?:,v1=[0-9a-f]{64})+)$/D', $header, $match)) {
            return false;
        }
        [, $timestamp, $signatures] = $match;
        if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
            return false;
        }
        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
        foreach (explode(',', substr($signatures, 1)) as $part) {
            if (hash_equals($expected, substr($part, 3))) {
                return true;
            }
        }

        return false;
    }
}
```

`app/Http/Controllers/CheckoutController.php`:

```php
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\Railbed;
use App\Services\RailbedException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class CheckoutController extends Controller
{
    public function store(Request $request, Railbed $railbed)
    {
        $products = config('products');
        $productId = $request->input('product');
        if (! is_string($productId) || ! array_key_exists($productId, $products)) {
            abort(400, 'Unknown product.');
        }
        $product = $products[$productId];

        $order = Order::create([
            'id' => 'order_'.Str::uuid(),
            'product_id' => $productId,
            'amount' => $product['amount'],
            'currency' => $product['currency'],
            'livemode' => Railbed::isLive(),
            'user_id' => $request->user()?->id,
        ]);

        $appUrl = rtrim(config('app.url'), '/');
        try {
            $session = $railbed->request('POST', '/checkout_sessions', [
                'amount' => $product['amount'],
                'currency' => $product['currency'],
                'description' => $product['name'],
                'reference' => $order->id,
                'metadata' => ['order_id' => $order->id, 'product_id' => $productId],
                'success_url' => $appUrl.'/checkout/success?order={REFERENCE}',
                'cancel_url' => $appUrl.'/',
            ], $order->id);
        } catch (RailbedException|ConnectionException $e) {
            Log::error('Railbed: checkout session not created', [
                'order' => $order->id,
                'code' => $e instanceof RailbedException ? $e->errorCode : 'connection',
            ]);

            $message = "We couldn't start the payment. Please try again.";

            return $request->expectsJson()
                ? response()->json(['error' => $message], 502)
                : back()->withErrors(['payment' => $message]);
        }

        $order->update(['payment_id' => $session['id']]);

        return $request->expectsJson()
            ? response()->json(['url' => $session['url']])
            : redirect()->away($session['url'], 303);
    }

    /** Shows the order's status from the database. It never marks anything paid: only the webhook does (R2). */
    public function success(Request $request)
    {
        $id = $request->query('order');
        $order = is_string($id) ? Order::find($id) : null;

        return response()->view('checkout.success', ['order' => $order])->header('Cache-Control', 'no-store');
    }
}
```

`app/Http/Controllers/RailbedWebhookController.php`:

```php
<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Services\Railbed;
use App\Services\RailbedException;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

class RailbedWebhookController extends Controller
{
    public function __invoke(Request $request, Railbed $railbed)
    {
        $raw = $request->getContent(); // the exact bytes Railbed signed: verify before parsing
        $secret = (string) config('services.railbed.webhook_secret');
        if ($secret === '' || ! Railbed::verifySignature($raw, (string) $request->header('Railbed-Signature'), $secret)) {
            return response('Invalid signature', 400);
        }

        $event = json_decode($raw, true);
        $paymentId = $event['data']['payment']['id'] ?? null;
        if (! str_starts_with((string) ($event['type'] ?? ''), 'payment.') || ! is_string($paymentId)) {
            return response('', 200);
        }
        if (($event['livemode'] ?? null) !== Railbed::isLive()) {
            Log::error('Railbed webhook: mode mismatch. RAILBED_SECRET_KEY and RAILBED_WEBHOOK_SECRET must be the same mode.');

            return response('Mode mismatch', 500);
        }

        try {
            $payment = $railbed->request('GET', '/payments/'.rawurlencode($paymentId));
        } catch (RailbedException $e) {
            // "Send test event" samples carry a made-up payment the API doesn't know: acknowledge them.
            $sample = ! $event['livemode'] && (($event['data']['payment']['metadata']['sample'] ?? null) === 'true');
            if ($e->status === 404 && $sample) {
                return response('', 200);
            }
            Log::error('Railbed webhook: payment not read', ['event' => $event['id'], 'code' => $e->errorCode]);

            return response('Try again later', 500);
        }

        $order = Order::where('payment_id', $payment['id'])->first();
        if (! $order) {
            return response('', 200); // not a payment this app created
        }
        if ($payment['livemode'] !== $order->livemode
            || $payment['reference'] !== $order->id
            || $payment['amount'] !== $order->amount
            || $payment['currency'] !== $order->currency) {
            Log::error('Railbed webhook: payment does not match its order', ['payment' => $payment['id'], 'order' => $order->id]);

            return response('', 200);
        }

        // Exactly once (playbook 6.4, step 8): the event, the paid update and the grant commit together.
        $result = DB::transaction(function () use ($event, $payment, $order) {
            $inserted = DB::table('railbed_events')->insertOrIgnore([
                'id' => $event['id'],
                'type' => $event['type'],
                'payment_id' => $payment['id'],
                'received_at' => now(),
            ]);
            if ($inserted === 0) {
                return 'duplicate';
            }
            if ($payment['status'] === 'paid') {
                $changed = Order::whereKey($order->id)->where('status', '!=', 'paid')->update([
                    'status' => 'paid',
                    'paid_at' => Carbon::createFromTimestamp($payment['paid_at'] ?? time(), config('app.timezone')),
                ]);
                if ($changed === 1) {
                    $this->fulfil($order->fresh());

                    return 'fulfilled';
                }
            } elseif (in_array($payment['status'], ['held', 'failed', 'expired'], true)) {
                Order::whereKey($order->id)->where('status', '!=', 'paid')->update(['status' => $payment['status']]);
            }

            return 'recorded';
        });

        Log::info("Railbed webhook {$event['type']} {$event['id']}: {$result}");

        return response('', 200);
    }

    /**
     * Grants what the order bought. Runs once per order, inside the transaction that marks it paid.
     * This is the one project-specific piece: replace the body, for example
     *   User::whereKey($order->user_id)->update(['plan' => $order->product_id]);
     * Dispatch slow work (emails, shipping labels) with ->afterCommit() so it runs only once this commits.
     */
    private function fulfil(Order $order): void
    {
        Log::info("fulfil {$order->id} ({$order->product_id})");
    }
}
```

`routes/web.php`:

```php
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\RailbedWebhookController;

Route::post('/checkout', [CheckoutController::class, 'store'])->name('checkout');
Route::get('/checkout/success', [CheckoutController::class, 'success'])->name('checkout.success');
Route::post('/webhooks/railbed', RailbedWebhookController::class);
```

Exempt the webhook from CSRF: Railbed's request is signed, not a form. Laravel 11 and later, in `bootstrap/app.php`:

```php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['webhooks/railbed']);
})
```

Laravel 10: add `'webhooks/railbed'` to `$except` in `app/Http/Middleware/VerifyCsrfToken.php`.

`resources/views/checkout/success.blade.php`:

```blade
@php
    $messages = [
        'paid' => 'Payment received. Your order is confirmed.',
        'awaiting_payment' => 'Confirming your payment…',
        'held' => 'Your payment is being reviewed. We’ll email you once it’s done.',
        'failed' => 'This payment didn’t complete.',
        'expired' => 'This payment didn’t complete.',
    ];
    $waiting = $order?->status === 'awaiting_payment';
@endphp
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    @if ($waiting)
        <meta http-equiv="refresh" content="3">
    @endif
    <title>Your order</title>
</head>
<body>
<main>
    <h1>{{ $order ? ($messages[$order->status] ?? 'Checking your order…') : 'We couldn’t find that order.' }}</h1>
    @if ($waiting)
        <p>This usually takes a few seconds.</p>
    @endif
    @if (in_array($order?->status, ['failed', 'expired'], true))
        <p><a href="{{ url('/') }}">Try again</a></p>
    @endif
</main>
</body>
</html>
```

The Pay button, in any Blade view:

```blade
<form method="POST" action="{{ route('checkout') }}">
    @csrf
    <input type="hidden" name="product" value="pro">
    <button type="submit">Pay $49.00</button>
</form>
@error('payment')
    <p role="alert">{{ $message }}</p>
@enderror
```

With Inertia, an external redirect must be `Inertia::location($session['url'])` instead of `redirect()->away(…)`. With Livewire, `return $this->redirect($session['url']);` from the component's action.

## 4. The webhook endpoint

```bash
php artisan serve                                # http://localhost:8000
cloudflared tunnel --url http://localhost:8000   # in a second terminal; prints https://….trycloudflare.com
npx checkouts.md webhook https://<tunnel>.trycloudflare.com/webhooks/railbed
```

The CLI adds `RAILBED_WEBHOOK_SECRET` to `.env`. Restart `php artisan serve` (and run `php artisan config:clear` if config is cached).

## 5. Test it

1. Create an order through the app's own route. With CSRF on, the easiest is the browser: open the page with the Pay button and click it; the address bar shows `https://pay.railbed.io/p/pay_…`.
2. Pay it: choose **Simulate successful payment** on the test provider page, or run the playbook's `simulate.mjs` with the `pay_…` id ([7.1](https://checkouts.md/playbook.md#71-the-end-to-end-run)).
3. Check: `storage/logs/laravel.log` shows `Railbed webhook payment.paid evt_…: fulfilled`; `php artisan tinker --execute "dump(App\Models\Order::where('payment_id', 'pay_…')->value('status'));"` shows `paid`; `/checkout/success?order=order_…` says it's confirmed.
4. Run the other scenarios in the playbook's [7.2](https://checkouts.md/playbook.md#72-the-scenarios), including `replay.mjs`.
5. Record the ids under Evidence in `CHECKOUTS.md`, and delete the tunnel's endpoint.

## 6. Go live

1. Put `RAILBED_SECRET_KEY` (from `.env.production`, or `.env.railbed` with Vapor) and `APP_URL=https://…` into the production environment the way the project deploys (Forge's environment editor, Vapor's environment file, the server's `.env`), without printing the key. Then `php artisan migrate --force` and `php artisan config:cache` on the server.
2. Deploy.
3. Add the Live endpoint: `npx checkouts.md webhook https://yourapp.com/webhooks/railbed --live` (plus the same `--live-to` as when connecting, if you gave one). It writes the Live `RAILBED_WEBHOOK_SECRET` into the Live key's file; move it to production the same way, and run `php artisan config:cache` again.
4. Check: `npx checkouts.md status` (with that `--live-to`, if any); `curl -s -o /dev/null -w '%{http_code}\n' -X POST https://yourapp.com/webhooks/railbed -d '{}'` answers `400` (not `419`, which would mean the CSRF exemption is missing).
5. Set `mode: live` in `CHECKOUTS.md`, add the Live endpoint's id under Wiring and the checks under Evidence. The first real payment is the final evidence.

## 7. CHECKOUTS.md

Fill in [the template](https://checkouts.md/templates/CHECKOUTS.md) with `webhook: /webhooks/railbed`. The Wiring section for this recipe:

```markdown
## Wiring

- `config/products.php`: the products and their prices.
- `app/Http/Controllers/CheckoutController.php`: `store` saves the order, creates the checkout session (Idempotency-Key = order id), redirects; `success` shows status only, never marks paid.
- `app/Http/Controllers/RailbedWebhookController.php`: verifies Railbed-Signature, re-reads the payment, applies it once in a transaction (`fulfil()` is the grant).
- `app/Services/Railbed.php`: the API client and signature check. `app/Models/Order.php`: the order.
- `routes/web.php`, `bootstrap/app.php`: the routes; `webhooks/railbed` is exempt from CSRF.
- `database/migrations/2026_10_14_000000_create_railbed_tables.php`: the `orders` and `railbed_events` tables.
- Webhook endpoints: Test `we_…` (tunnel, removed after testing), Live `we_…`, at `/webhooks/railbed`.
- Secrets: `RAILBED_SECRET_KEY`, `RAILBED_WEBHOOK_SECRET`. Test in `.env` (gitignored); Live in the production environment.
```

## Notes

- **`419` from the webhook** means the CSRF exemption is missing; **`401` or a redirect to `/login`** means an auth middleware covers it. Railbed's request needs neither.
- **Octane or queues:** `fulfil()` runs inside the request's transaction. Queue anything slow with `->afterCommit()`.
- **Plain PHP or WordPress** (no Laravel): the same verification as the playbook's PHP function, the raw body from `file_get_contents('php://input')`, and the header from `$_SERVER['HTTP_RAILBED_SIGNATURE']`.
