Paid Bookings
This chapter turns the site into a till. A visitor picks a session, pays with Stripe Checkout, and gets a confirmation email in their language. The session's seat count goes down by itself, and the school sees the booking in Stripe and in its own database.
Lynx keeps the content: the course, the session, its price and its seats are pages and fields that staff edit. The bookings are your app's data, in a table of their own. With that split, you can sell through Lynx without a commerce module.
Out of scope here, and worth planning for a real school: refunds, invoices and VAT. Stripe's dashboard handles refunds, and Stripe Tax and invoicing cover the rest.
The bookings table
A booking points at its session page by id, and keeps who booked, in which language, and what Stripe said:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Bookings are the app's own data, next to Lynx's pages: a booking points at a session page by id.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('bookings', function (Blueprint $table): void {
$table->id();
$table->uuid('uuid')->unique();
$table->unsignedBigInteger('session_id')->index();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('email');
$table->string('language', 16);
$table->unsignedInteger('amount');
$table->string('currency', 3);
$table->string('status', 16)->default('pending');
$table->string('stripe_session')->nullable()->unique();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('bookings');
}
};The Booking model reads its session back with lynx()->pages->get($this->session_id).
Booking and paying
The session page shows the seats left and a booking form, which posts to a route of your own. Lynx's routes serve the pages; routes/web.php keeps the app's:
Route::post('/book/{session}', [BookingController::class, 'store'])
->whereNumber('session')->middleware('throttle:10,1')->name('bookings.store');
Route::get('/bookings/{uuid}', [BookingController::class, 'show'])
->whereUuid('uuid')->name('bookings.show');
// Stripe signs its webhooks instead of sending a CSRF token
Route::post('/stripe/webhook', StripeWebhookController::class)
->withoutMiddleware(ValidateCsrfToken::class)->name('stripe.webhook');The controller checks that the session is still open, records a pending booking, and sends the buyer to Stripe:
/**
* Books a place on a session: checks it is still open, records a pending booking, and sends the
* buyer to Stripe to pay. Stripe's webhook confirms it (StripeWebhookController).
*/
final class BookingController
{
private const string CURRENCY = 'eur';
public function store(Request $request, int $session, StripeCheckout $checkout): RedirectResponse
{
$page = lynx()->pages->get($session);
abort_unless($page instanceof Page && $page->template()->name === 'session', 404);
// This route is the app's, outside Lynx's /pt/ URLs, so the form says the language.
$buyer = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email', 'max:190'],
'language' => ['required', 'string', fn (string $attribute, mixed $code, Closure $fail) => lynx()->languages->find((string) $code) ?? $fail('Unknown language.')],
]);
// A copy without Stripe keys, such as a fresh clone of the starter, can't take payments yet.
if (blank(config('atlas.stripe.secret'))) {
return back()->withErrors(['session' => __('Online payment is not set up yet. Please contact us to book.')]);
}
if (! Seats::areLeft($page)) {
return back()->withErrors(['session' => __('This session is full or no longer open.')]);
}
$course = $page->parent();
$booking = Booking::query()->create([
...$buyer,
'uuid' => (string) Str::uuid(),
'session_id' => $page->id,
'user_id' => $request->user()?->getAuthIdentifier(),
'amount' => (int) round(100 * (float) (string) ($page->price ?? $course?->price)),
'currency' => self::CURRENCY,
]);
return redirect()->away($checkout->urlFor($booking, (string) $page->title));
}
/** Where Stripe sends the buyer back: paid, or still waiting for the webhook. */
public function show(string $uuid): string
{
$booking = Booking::query()->where('uuid', $uuid)->firstOrFail();
return lynx()->language($booking->language, fn (): string => view('bookings.show', [
'booking' => $booking,
'page' => $booking->session() ?? abort(404),
])->render());
}
}The price comes from the session, or from its course when the session has none, so staff change prices in the page editor and never in code. The form sends the visitor's language, because /book/42 is the app's URL and has no /pt/ in it.
Stripe Checkout takes the payment on Stripe's own page, so card details never reach your server. Creating the session is one HTTP call:
/**
* Creates Stripe Checkout Sessions over Stripe's REST API with Laravel's HTTP client: no SDK
* needed, and tests fake it with Http::fake(). stripe/stripe-php does the same in more words.
*/
final class StripeCheckout
{
/** Stripe's page where the buyer pays. */
public function urlFor(Booking $booking, string $description): string
{
$session = Http::asForm()
->withToken((string) config('atlas.stripe.secret'))
->post('https://api.stripe.com/v1/checkout/sessions', [
'mode' => 'payment',
'customer_email' => $booking->email,
'client_reference_id' => $booking->uuid,
'locale' => $booking->language === 'pt' ? 'pt' : 'en',
'line_items' => [[
'quantity' => 1,
'price_data' => [
'currency' => $booking->currency,
'unit_amount' => $booking->amount,
'product_data' => ['name' => $description],
],
]],
'success_url' => route('bookings.show', $booking->uuid),
'cancel_url' => $booking->session()?->url,
])
->throw()
->json();
$booking->update(['stripe_session' => $session['id']]);
return $session['url'];
}
}The webhook
Stripe tells your app a payment succeeded by posting to a webhook. Anyone could post to that URL, so the webhook first checks Stripe's signature:
/**
* Checks the Stripe-Signature header of a webhook: an HMAC of "timestamp.payload" with the
* endpoint's secret, no older than five minutes. Without this check anyone could post a fake
* "paid" event to the webhook.
*/
final class StripeSignature
{
private const int TOLERANCE_SECONDS = 300;
public static function isValid(string $payload, string $header, string $secret): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$parts[$key][] = $value;
}
$timestamp = (int) ($parts['t'][0] ?? 0);
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
return $secret !== ''
&& abs(time() - $timestamp) <= self::TOLERANCE_SECONDS
&& array_any($parts['v1'] ?? [], fn (string $signature): bool => hash_equals($expected, $signature));
}
}Then it confirms the booking. Stripe may send the same event more than once, so the booking is confirmed, a seat taken and the email sent only the first time:
/**
* Stripe calls this when a Checkout Session completes. Stripe may call more than once, so a
* booking is confirmed, a seat taken and the email sent only the first time.
*/
final class StripeWebhookController
{
public function __invoke(Request $request): Response
{
$payload = $request->getContent();
if (! StripeSignature::isValid($payload, (string) $request->header('Stripe-Signature'), (string) config('atlas.stripe.webhook_secret'))) {
Log::warning('Refused a Stripe webhook with a bad signature.', ['ip' => $request->ip()]);
return response('Invalid signature', 400);
}
$event = json_decode($payload, true);
if (($event['type'] ?? null) === 'checkout.session.completed' && ($event['data']['object']['payment_status'] ?? null) === 'paid') {
$this->confirm((string) $event['data']['object']['id']);
}
// Stripe retries anything but a 2xx, so other events are acknowledged and ignored.
return response('OK');
}
private function confirm(string $stripeSession): void
{
$booking = DB::transaction(function () use ($stripeSession): ?Booking {
$booking = Booking::query()->where('stripe_session', $stripeSession)->lockForUpdate()->first();
if ($booking === null || $booking->isPaid()) {
return null;
}
$booking->update(['status' => 'paid', 'paid_at' => now()]);
return $booking;
});
if ($booking !== null) {
Seats::take($booking->session_id);
Mail::to($booking->email)->send(new BookingConfirmed($booking));
}
}
}In Stripe's dashboard, add the endpoint https://your-site/stripe/webhook for the checkout.session.completed event, and put its signing secret in .env as STRIPE_WEBHOOK_SECRET, next to STRIPE_SECRET. On your machine, stripe listen --forward-to localhost:8000/stripe/webhook forwards test events.
Seats
The seats left are the session page's seats field, the same one staff edit. Taking a seat saves the page as the system, since the buyer is not an editor, and the page's history shows the change:
/**
* The seats left on a session live in its page's "seats" field, which staff also edit. Taking one
* saves the page as the system: the buyer is not an editor, and the change shows in the page's
* history as Atlas's own.
*/
final class Seats
{
public static function areLeft(Page $session): bool
{
return $session->availability?->value === 'scheduled' && (int) $session->seats > 0 && $session->starts?->isFuture();
}
/** Takes one seat. A lock keeps two payments that arrive together from both taking the last. */
public static function take(int $sessionId): void
{
Cache::lock("atlas:seats:{$sessionId}", 10)->block(5, function () use ($sessionId): void {
lynx()->actingAs(Actor::system(), function () use ($sessionId): void {
$session = lynx()->pages->get($sessionId);
$session->seats = max(0, (int) $session->seats - 1);
$session->save();
});
});
}
}The lock keeps two payments that arrive at the same moment from both taking the last seat. Once seats reach zero, the booking form says the session is full, and the controller refuses it even if an old page is still open in someone's browser.
Session pages will be served from the page cache in chapter 10, so the seats and the form sit in an island: the stored page holds a placeholder, and every visitor gets the current count.
<x-lynx-island view="partials.session.booking">
<p>…</p>
</x-lynx-island>The confirmation email
The email is a queued mailable. It reads the course, date and studio in the buyer's language when it is built, so the queue worker that sends it later can't get the language wrong:
/**
* The booking confirmation, in the language the buyer booked in. Everything it shows is read in
* that language when the mail is built, so it stays right when a queue worker sends it later.
*/
final class BookingConfirmed extends Mailable implements ShouldQueue
{
use Queueable;
public string $course;
public string $when;
public string $where;
public function __construct(public Booking $booking)
{
$this->locale($booking->language);
lynx()->language($booking->language, function () use ($booking): void {
$session = $booking->session();
$this->course = (string) $session?->parent()?->title;
$this->when = (string) $session?->starts?->locale($booking->language)->isoFormat('LLLL');
$this->where = (string) $session?->location?->title;
});
}
public function envelope(): Envelope
{
return new Envelope(subject: __('Your place on :course', ['course' => $this->course]));
}
public function content(): Content
{
return new Content(markdown: 'mail.booking-confirmed');
}
}Its texts go through __(), with the Portuguese in lang/pt.json. Lynx reads lang/pt.json for the pt-PT locale too.
What you get
- The client: paid bookings with no manual step: the money in Stripe, the seat counted, the buyer emailed in their language.
- You: content and transactions kept apart, prices that staff own, a webhook that is safe against forgery and repeats, and tests that run the whole flow with a faked Stripe.