Why Laravel Octane for a Ticket Booking Website
Ticketing systems are among the most concurrency-sensitive web apps you can build. When hundreds or thousands of users try to buy the same seats within seconds, you need precise locking, efficient I/O, and a fault-tolerant checkout pipeline. Laravel Octane supercharges Laravel by running it on long-lived workers (Swoole or RoadRunner), eliminating per-request bootstrapping overhead and unlocking concurrency features for blazing throughput.
In this guide, you’ll build a production-grade ticket booking website using Laravel Octane. We’ll cover:
- Architecture and data modeling for events, shows, seats, and orders
- Concurrency-safe seat locking and inventory guarantees
- Payment flow with idempotency and webhooks
- Octane configuration, performance tuning, and pitfalls to avoid
- Real-time UX updates, caching, and SEO practices for event pages
- Practical code snippets and deployment advice
By the end, you’ll have a clear blueprint for a scalable, secure, and ultra-fast ticketing platform.
What Octane Changes vs Classic PHP-FPM
Laravel Octane runs your app as a long-lived process. Instead of booting the framework on every request, services remain in memory, which means:
- Lower latency and higher throughput
- Ability to concurrently run tasks (I/O-bound) using Octane helpers
- Warmed services (DB, cache, router) reducing startup overhead
However, long-lived processes introduce new responsibilities:
- Avoid state leakage: don’t mutate singletons across requests
- Use drivers safe for multiple workers (e.g., Redis for sessions and cache)
- Be mindful of memory usage; restart workers occasionally
A typical local dev startup:
php artisan octane:install
php artisan octane:start --server=swoole --watch
Production is similar, minus --watch and with explicit worker counts.
Core Requirements of a Ticketing Platform
- Events, venues, shows/schedules
- Seat maps (reserved seating) and/or zones (general admission)
- Atomic seat holds and purchases
- Payment processing (Stripe/Adyen/etc.) with idempotency and webhooks
- Confirmation emails and QR-code tickets
- Real-time seat availability updates
- Observability, caching, and SEO best practices
We’ll focus on reserved seating with sample code; you can adapt to general admission by treating inventory as simple counters per zone.
Data Model and Migrations
Design for both correctness and performance. Here’s a pragmatic schema:
- venues: id, name, address
- events: id, title, slug, description
- shows: id, event_id, venue_id, starts_at, ends_at
- seats: id, venue_id, section, row, number, seat_uid (unique)
- seat_maps: show_id, seat_id, base_price, status (optional over-ride for show)
- reservations: id, show_id, seat_id, user_id (nullable), status (held/confirmed/cancelled), hold_expires_at
- orders: id, user_id, show_id, total_amount, status (pending/paid/failed), payment_intent_id, idempotency_key
- tickets: id, order_id, seat_id, qr_code, issued_at
One way is to avoid a separate seat_maps table and compute pricing via pricing rules. For now, we’ll keep it simple.
Sample migrations (simplified)
// database/migrations/2025_01_01_000001_create_shows_table.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('shows', function (Blueprint $table) {
$table->id();
$table->foreignId('event_id')->constrained();
$table->foreignId('venue_id')->constrained();
$table->timestamp('starts_at');
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['event_id', 'starts_at']);
});
}
public function down(): void
{
Schema::dropIfExists('shows');
}
};
// database/migrations/2025_01_01_000002_create_seats_table.php
return new class extends Migration {
public function up(): void
{
Schema::create('seats', function (Blueprint $table) {
$table->id();
$table->foreignId('venue_id')->constrained();
$table->string('section')->nullable();
$table->string('row')->nullable();
$table->string('number')->nullable();
$table->string('seat_uid')->unique(); // e.g., VENUE1-A-12
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('seats');
}
};
// database/migrations/2025_01_01_000003_create_reservations_table.php
return new class extends Migration {
public function up(): void
{
Schema::create('reservations', function (Blueprint $table) {
$table->id();
$table->foreignId('show_id')->constrained();
$table->foreignId('seat_id')->constrained();
$table->foreignId('user_id')->nullable()->constrained();
$table->enum('status', ['held', 'confirmed', 'cancelled'])->index();
$table->timestamp('hold_expires_at')->nullable()->index();
$table->timestamps();
// Prevent duplicate active reservations for the same show+seat
$table->unique(['show_id', 'seat_id', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('reservations');
}
};
// database/migrations/2025_01_01_000004_create_orders_table.php
return new class extends Migration {
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('show_id')->constrained();
$table->unsignedInteger('total_amount'); // in cents
$table->enum('status', ['pending', 'paid', 'failed'])->index();
$table->string('payment_intent_id')->nullable()->index();
$table->string('idempotency_key')->unique();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
// database/migrations/2025_01_01_000005_create_tickets_table.php
return new class extends Migration {
public function up(): void
{
Schema::create('tickets', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained();
$table->foreignId('seat_id')->constrained();
$table->string('qr_code')->unique();
$table->timestamp('issued_at');
$table->timestamps();
$table->unique(['order_id', 'seat_id']);
});
}
public function down(): void
{
Schema::dropIfExists('tickets');
}
};
Actionable tip: add foreign key indexes and keep columns narrow. For pricing, store cents as integers to avoid floating-point errors.
Seat Locking and Concurrency Control
Overselling is the cardinal sin. You need atomic holds and time-bound reservations. Here are complementary strategies:
- Database pessimistic locks
- Use transactions with SELECT ... FOR UPDATE or Eloquent’s lockForUpdate.
- Pros: strong consistency. Cons: can be slower under extreme contention.
- Unique constraints
- Combined unique constraints (show_id, seat_id, status) prevent double-held or double-confirmed rows.
- Redis-based short holds
- Use Redis SET NX with TTL to quickly reserve something for N seconds.
- Great for speed; final authority remains the database on checkout.
A practical hybrid: first claim seats in Redis for a short hold (e.g., 5 minutes), then persist a ‘held’ reservation row in the DB within a transaction. If persistence fails, release the Redis holds.
Example: ReservationService
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\Reservation;
class ReservationService
{
// Hold seats for N minutes
public function holdSeats(int $showId, array $seatIds, ?int $userId = null, int $minutes = 5): array
{
$now = now();
$expiresAt = $now->copy()->addMinutes($minutes);
$claimed = [];
// 1) Optimistic, fast path in Redis
foreach ($seatIds as $seatId) {
$key = "hold:show:{$showId}:seat:{$seatId}";
$ok = Cache::store('redis')->add($key, $expiresAt->timestamp, $minutes * 60);
if ($ok) {
$claimed[] = $seatId;
}
}
if (count($claimed) !== count($seatIds)) {
// Release partial holds
foreach ($claimed as $seatId) {
Cache::store('redis')->forget("hold:show:{$showId}:seat:{$seatId}");
}
throw new \RuntimeException('Some seats are no longer available.');
}
// 2) Authoritative persistence in DB within a transaction
DB::transaction(function () use ($showId, $seatIds, $userId, $expiresAt) {
foreach ($seatIds as $seatId) {
Reservation::create([
'show_id' => $showId,
'seat_id' => $seatId,
'user_id' => $userId,
'status' => 'held',
'hold_expires_at' => $expiresAt,
]);
}
}, 3);
return ['hold_expires_at' => $expiresAt, 'seats' => $seatIds];
}
public function confirmSeats(int $showId, array $seatIds, int $orderId): void
{
DB::transaction(function () use ($showId, $seatIds) {
$holds = Reservation::where('show_id', $showId)
->whereIn('seat_id', $seatIds)
->where('status', 'held')
->where('hold_expires_at', '>', now())
->lockForUpdate()
->get();
if ($holds->count() !== count($seatIds)) {
throw new \RuntimeException('Holds expired or missing.');
}
// Confirm and release Redis
foreach ($holds as $hold) {
$hold->update(['status' => 'confirmed', 'hold_expires_at' => null]);
Cache::store('redis')->forget("hold:show:{$hold->show_id}:seat:{$hold->seat_id}");
}
}, 3);
}
public function cancelHold(int $showId, array $seatIds): void
{
DB::transaction(function () use ($showId, $seatIds) {
Reservation::where('show_id', $showId)
->whereIn('seat_id', $seatIds)
->where('status', 'held')
->delete();
});
foreach ($seatIds as $seatId) {
Cache::store('redis')->forget("hold:show:{$showId}:seat:{$seatId}");
}
}
}
In addition, run a scheduled job to release expired holds:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->call(function () {
$expired = \App\Models\Reservation::where('status', 'held')
->where('hold_expires_at', '<=', now())
->get();
foreach ($expired as $res) {
Cache::store('redis')->forget("hold:show:{$res->show_id}:seat:{$res->seat_id}");
$res->delete();
}
})->everyMinute();
}
Actionable tip: Use a Lua script for atomic Redis multi-seat claims under extreme load. The approach above is adequate for most cases and defers final guarantees to the DB confirmation step.
Checkout and Payments (Stripe example)
Payments must be idempotent and resilient to network retries. With Stripe Payment Intents:
- Create an order with status pending
- Generate a Payment Intent and keep its ID on the order
- Use an idempotency_key per checkout attempt
- Confirm seats only after webhook confirms payment is succeeded
Controller snippet
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use App\Models\Order;
use App\Services\ReservationService;
use Stripe\StripeClient;
class CheckoutController extends Controller
{
public function createIntent(Request $request, ReservationService $res)
{
$validated = $request->validate([
'show_id' => 'required|integer',
'seat_ids' => 'required|array|min:1',
'seat_ids.*' => 'integer',
]);
$idempotencyKey = Str::uuid()->toString();
$userId = $request->user()->id;
// 1) Hold seats
$res->holdSeats($validated['show_id'], $validated['seat_ids'], $userId);
// 2) Compute total (stub)
$total = count($validated['seat_ids']) * 5000; // $50 each
// 3) Create order
$order = Order::create([
'user_id' => $userId,
'show_id' => $validated['show_id'],
'total_amount' => $total,
'status' => 'pending',
'idempotency_key' => $idempotencyKey,
]);
// 4) Create Payment Intent (Stripe)
$stripe = new StripeClient(config('services.stripe.secret'));
$intent = $stripe->paymentIntents->create([
'amount' => $total,
'currency' => 'usd',
'metadata' => [
'order_id' => (string) $order->id,
],
], [
'idempotency_key' => $idempotencyKey,
]);
$order->update(['payment_intent_id' => $intent->id]);
return response()->json([
'client_secret' => $intent->client_secret,
'order_id' => $order->id,
]);
}
}
Webhook handler
Confirm on the webhook to avoid client-side fraud or race conditions.
// app/Http/Controllers/StripeWebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Order;
use App\Models\Ticket;
use App\Services\ReservationService;
use Illuminate\Support\Str;
class StripeWebhookController extends Controller
{
public function __invoke(Request $request, ReservationService $res)
{
$event = $request->all();
if (($event['type'] ?? null) !== 'payment_intent.succeeded') {
return response()->json(['received' => true]);
}
$intent = $event['data']['object'];
$order = Order::where('payment_intent_id', $intent['id'])->first();
if (!$order || $order->status === 'paid') {
return response()->json(['ok' => true]);
}
// Confirm seats and issue tickets
// Find held seats for the show by this user; in a real app, join reservations
$heldSeats = \App\Models\Reservation::where('show_id', $order->show_id)
->where('status', 'held')
->whereNotNull('hold_expires_at')
->where('hold_expires_at', '>', now())
->get();
$seatIds = $heldSeats->pluck('seat_id')->all();
$res->confirmSeats($order->show_id, $seatIds, $order->id);
foreach ($seatIds as $seatId) {
Ticket::create([
'order_id' => $order->id,
'seat_id' => $seatId,
'qr_code' => Str::uuid()->toString(),
'issued_at' => now(),
]);
}
$order->update(['status' => 'paid']);
// TODO: dispatch email job with tickets
return response()->json(['ok' => true]);
}
}
Actionable tips:
- Always verify event signatures from Stripe for security
- Use idempotency on both createIntent and webhook handling
- Expire or cancel holds on payment failure/timeouts
Leveraging Laravel Octane for Performance
Octane brings configuration options that materially improve response times.
Key settings in config/octane.php
return [
'server' => env('OCTANE_SERVER', 'swoole'),
'workers' => env('OCTANE_WORKERS', 8),
'task_workers' => env('OCTANE_TASK_WORKERS', 4),
'max_requests' => env('OCTANE_MAX_REQUESTS', 500),
'garbage' => [
'collect' => true,
],
'watch' => [
'enabled' => false,
],
'warm' => [
Illuminate\Database\DatabaseManager::class,
Illuminate\Cache\CacheManager::class,
Illuminate\Redis\RedisManager::class,
Illuminate\Routing\Router::class,
],
];
- workers: number of HTTP workers; test for CPU cores and memory
- task_workers: for background Octane tasks and non-blocking workloads
- max_requests: periodically recycle to avoid memory bloat
Don’t leak state across requests
Because workers stay alive, mutating singletons or using static, request-scoped variables in global state can cause data leaks and odd bugs. Practical measures:
- Resolve services per request where practical
- Avoid adding mutable request data to singletons
- Use Octane’s built-in table/cache only for ephemeral, non-critical data; prefer Redis for shared state across workers
Concurrent I/O with Octane
When you need to fetch multiple resources (e.g., pricing rules, coupon validation, recommendations), run concurrent tasks:
use Laravel\Octane\Facades\Octane;
[$pricing, $coupon] = Octane::concurrently([
fn () => app(\App\Services\PricingService::class)->forShow($showId),
fn () => app(\App\Services\CouponService::class)->validate($couponCode),
]);
This can shave milliseconds when hitting external APIs or caches.
Caching Strategy That Plays Nice with Octane
- Use Redis as your cache/session driver so multiple workers share state
- Use route, config, and view caches
php artisan route:cache
php artisan config:cache
php artisan view:cache
- Cache static seat maps and event pages with short TTLs (e.g., 30–120s)
- For SEO pages (event listings), enable HTTP caching with ETags or Cache-Control
Example:
// Controller method for an event page
$response = response()->view('events.show', $data);
$response->setEtag(md5($event->updated_at . $show->updated_at));
$response->setPublic();
$response->setMaxAge(60);
return $response;
Actionable tip: If using an edge CDN, cache images, seat maps JSON, and public pages aggressively, while keeping checkout endpoints uncacheable.
Real-Time Seat Updates
Users should see seats turn unavailable as others hold them. Options:
- Laravel WebSockets or Pusher
- Swoole WebSocket server (when using Octane with Swoole)
Broadcast seat updates when a hold is created/expired/confirmed:
// app/Events/SeatStatusChanged.php
class SeatStatusChanged implements ShouldBroadcast
{
public $showId;
public $seatId;
public $status; // available|held|confirmed
public function __construct($showId, $seatId, $status)
{
$this->showId = $showId;
$this->seatId = $seatId;
$this->status = $status;
}
public function broadcastOn()
{
return new Channel('show-'.$this->showId);
}
}
Call this event from ReservationService when seats are held/confirmed/cancelled.
Rate Limiting and Bot Defense
High-demand on-sales attract bots. Protect inventory and UX:
- Laravel’s throttle middleware for sensitive endpoints
- Invisible reCAPTCHA or hCaptcha on checkout actions
- Queue-based email and webhook handlers to keep response fast
- Optional waitroom/queue system during massive drops
Route::middleware(['throttle:30,1'])->group(function () {
Route::post('/checkout/create-intent', [CheckoutController::class, 'createIntent']);
});
Observability and Robustness
- Logging: channel per subsystem (payments, reservations)
- Metrics: export counters (holds created, holds expired, checkout conversions)
- Tracing: OpenTelemetry exporters if you’re using RoadRunner or supported agents
- Health checks: database, Redis, queue workers (Horizon)
Laravel Horizon pairs well with Redis to monitor queues. With Octane, keep queue workers separate (do not rely on Octane workers for queues):
php artisan horizon
Testing Concurrency and Races
- Use Laravel’s Parallel Testing to simulate multiple buyers
- Write stress tests with k6 or Artillery hitting hold and checkout endpoints
- Add tests for expiration flows
Example pest test idea:
it('prevents double-selling', function () {
// Attempt to hold the same seat from two concurrent requests
// Assert only one succeeds; the other receives a 409/422
});
Deployment with Octane
Nginx reverse proxy
Run Octane on 127.0.0.1:8000 and proxy from Nginx:
server {
listen 80;
server_name tickets.example.com;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:8000;
}
}
Systemd service
[Unit]
Description=Laravel Octane
After=network.target
[Service]
User=www-data
Restart=always
ExecStart=/usr/bin/php /var/www/html/artisan octane:start --server=swoole --workers=8 --task-workers=4 --max-requests=500
ExecStop=/usr/bin/php /var/www/html/artisan octane:stop
[Install]
WantedBy=multi-user.target
Docker notes
- Install the Swoole PHP extension (or use RoadRunner binary)
- Distroless or Alpine images keep attack surface small
- Pin PHP versions (8.2/8.3) and optimize opcache
Actionable tip: Blue/green deploy with health checks ensures no downtime. Pre-run migrations, warm caches, then switch traffic.
Common Octane Pitfalls (and fixes)
- Mutating singletons across requests: don’t store user-specific state in services
- Using in-memory Octane cache for critical inventory: use Redis instead, because Octane cache is per-worker
- Long-running memory growth: tune max_requests and monitor with system metrics
- Blocking calls: offload to task workers, queues, or Octane::concurrently
UX and SEO for Event Pages
Even transactional sites benefit from search traffic.
- Use schema.org/Event JSON-LD with show dates and location
- Pre-render open graph tags (title, image, start date)
- Lazy load seat map images, compress assets
- Keep server response <200ms for event pages (Octane helps)
- Clear URL structure: /events/{slug}/{date} and canonical tags for multiple dates
Example JSON-LD:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Rock Night 2025",
"startDate": "2025-06-15T20:00:00",
"location": {
"@type": "Place",
"name": "Downtown Arena",
"address": "123 Main St, City"
}
}
</script>
Putting It All Together: Example Flow
- User selects show and seats; client calls POST /checkout/create-intent
- Server holds seats in Redis + DB and returns Stripe client_secret
- User completes payment on the client; Stripe fires webhook
- Webhook confirms payment, transitions reservations to confirmed, and issues tickets
- Real-time updates broadcast seat status to other users
- Expired holds get cleaned up by scheduler; failed payments release holds
With Octane, this flow stays fast—even under heavy concurrency—because the app avoids repeated bootstrapping, can perform concurrent I/O, and leverages warmed services.
Performance Checklist
- Octane workers sized to CPU cores; benchmark with wrk/k6
- Redis for cache+sessions; database tuned with proper indexes
- Route/config/view caches enabled
- N+1 queries eliminated via eager loading
- Queue all non-critical paths (emails, PDF generation)
- CDN in front for static assets and public pages
- Health checks and restarts via systemd or process manager
Security Essentials
- Validate inputs server-side; sanitize seat IDs and show IDs
- Enforce CSRF for forms; use SameSite and secure cookies
- Verify payment webhooks with signatures
- Do not expose internal IDs in a way that’s guessable for other shows
- Use per-order idempotency keys; guard against duplicate webhooks
- Log and alert on anomalous patterns (too many holds from same IP)
Extending the System
- General admission: replace per-seat holds with atomic Redis counters and DB decrements
- Dynamic pricing: adjust base_price by demand; cache with TTL and lock strategies
- Resale/transfer: add ticket ownership transfers and QR regeneration
- Entry scanning: mobile app that validates QR and marks ticket as used
Final Thoughts
A ticket booking website demands correctness-first concurrency with performance to match. By combining Laravel Octane’s long-lived workers, Redis-backed holds, transactional confirmation, and idempotent payments, you can build a platform that’s both fast and reliable.
Focus on preventing oversells with layered safeguards, keep state safe across Octane workers, and invest in observability so you can spot issues early. With the patterns above, you’ll be ready to handle launch-day spikes and deliver a smooth checkout experience.
Happy building—and may your on-sale days be boringly stable!