Event platforms require secure payment handling with organizer payouts and attendee protection. Stripe Connect with escrow patterns ensures trust for all parties. At ZIRA Software, we've built event platforms processing millions in ticket sales.
Database Schema
Schema::create('organizers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('company_name');
$table->string('stripe_account_id')->nullable();
$table->boolean('stripe_onboarding_complete')->default(false);
$table->boolean('payouts_enabled')->default(false);
$table->decimal('platform_fee_percent', 5, 2)->default(5.00);
$table->timestamps();
});
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->foreignId('organizer_id')->constrained();
$table->string('title');
$table->string('slug')->unique();
$table->text('description');
$table->string('venue');
$table->dateTime('starts_at');
$table->dateTime('ends_at');
$table->enum('status', ['draft', 'published', 'cancelled', 'completed']);
$table->timestamps();
});
Schema::create('ticket_types', function (Blueprint $table) {
$table->id();
$table->foreignId('event_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->decimal('price', 10, 2);
$table->integer('quantity_available');
$table->integer('quantity_sold')->default(0);
$table->dateTime('sales_start')->nullable();
$table->dateTime('sales_end')->nullable();
$table->timestamps();
});
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('event_id')->constrained();
$table->string('stripe_payment_intent_id')->nullable();
$table->decimal('subtotal', 10, 2);
$table->decimal('platform_fee', 10, 2);
$table->decimal('total', 10, 2);
$table->enum('status', ['pending', 'paid', 'refunded', 'cancelled']);
$table->enum('payout_status', ['pending', 'held', 'released', 'paid'])->default('pending');
$table->timestamps();
});
Schema::create('tickets', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->onDelete('cascade');
$table->foreignId('ticket_type_id')->constrained();
$table->string('code')->unique();
$table->string('attendee_name');
$table->string('attendee_email');
$table->boolean('checked_in')->default(false);
$table->timestamp('checked_in_at')->nullable();
$table->timestamps();
});
Stripe Connect Onboarding
// app/Services/StripeConnectService.php
class StripeConnectService
{
private StripeClient $stripe;
public function __construct()
{
$this->stripe = new StripeClient(config('services.stripe.secret'));
}
public function createAccount(Organizer $organizer): string
{
$account = $this->stripe->accounts->create([
'type' => 'express',
'country' => 'US',
'email' => $organizer->user->email,
'capabilities' => [
'card_payments' => ['requested' => true],
'transfers' => ['requested' => true],
],
'business_type' => 'individual',
'metadata' => [
'organizer_id' => $organizer->id,
],
]);
$organizer->update(['stripe_account_id' => $account->id]);
return $this->createOnboardingLink($organizer);
}
public function createOnboardingLink(Organizer $organizer): string
{
$link = $this->stripe->accountLinks->create([
'account' => $organizer->stripe_account_id,
'refresh_url' => route('organizer.stripe.refresh'),
'return_url' => route('organizer.stripe.complete'),
'type' => 'account_onboarding',
]);
return $link->url;
}
public function checkAccountStatus(Organizer $organizer): void
{
$account = $this->stripe->accounts->retrieve($organizer->stripe_account_id);
$organizer->update([
'stripe_onboarding_complete' => $account->details_submitted,
'payouts_enabled' => $account->payouts_enabled,
]);
}
}
Payment with Escrow
// app/Services/TicketPaymentService.php
class TicketPaymentService
{
public function __construct(
private StripeClient $stripe,
) {}
public function createPaymentIntent(Order $order): PaymentIntent
{
$organizer = $order->event->organizer;
// Calculate fees
$platformFee = $order->subtotal * ($organizer->platform_fee_percent / 100);
$order->update([
'platform_fee' => $platformFee,
'total' => $order->subtotal + $platformFee,
]);
// Create payment intent - funds held by platform
$paymentIntent = $this->stripe->paymentIntents->create([
'amount' => (int) ($order->total * 100),
'currency' => 'usd',
'metadata' => [
'order_id' => $order->id,
'event_id' => $order->event_id,
'organizer_id' => $organizer->id,
],
// Don't transfer immediately - hold in escrow
'transfer_group' => "order_{$order->id}",
]);
$order->update([
'stripe_payment_intent_id' => $paymentIntent->id,
'payout_status' => 'held', // Funds held until event completes
]);
return $paymentIntent;
}
public function confirmPayment(Order $order): void
{
$order->update(['status' => 'paid']);
// Generate tickets
foreach ($order->items as $item) {
for ($i = 0; $i < $item->quantity; $i++) {
Ticket::create([
'order_id' => $order->id,
'ticket_type_id' => $item->ticket_type_id,
'code' => $this->generateTicketCode(),
'attendee_name' => $item->attendee_name,
'attendee_email' => $item->attendee_email,
]);
}
$item->ticketType->increment('quantity_sold', $item->quantity);
}
event(new TicketsPurchased($order));
}
}
Escrow Release After Event
// app/Services/PayoutService.php
class PayoutService
{
public function releaseEventPayouts(Event $event): void
{
if ($event->status !== 'completed') {
throw new EventNotCompletedException();
}
$organizer = $event->organizer;
$orders = $event->orders()->where('status', 'paid')->where('payout_status', 'held')->get();
foreach ($orders as $order) {
$this->releaseOrderPayout($order, $organizer);
}
}
private function releaseOrderPayout(Order $order, Organizer $organizer): void
{
$stripe = new StripeClient(config('services.stripe.secret'));
// Transfer to organizer (minus platform fee)
$transferAmount = ($order->subtotal) * 100; // Platform keeps fee
$transfer = $stripe->transfers->create([
'amount' => (int) $transferAmount,
'currency' => 'usd',
'destination' => $organizer->stripe_account_id,
'transfer_group' => "order_{$order->id}",
'metadata' => [
'order_id' => $order->id,
'event_id' => $order->event_id,
],
]);
$order->update([
'payout_status' => 'paid',
]);
event(new PayoutReleased($order, $transfer));
}
}
// Scheduled command to release payouts
// app/Console/Commands/ReleaseEventPayouts.php
class ReleaseEventPayouts extends Command
{
protected $signature = 'events:release-payouts';
public function handle(PayoutService $payoutService)
{
$completedEvents = Event::where('status', 'completed')
->where('ends_at', '<', now()->subDays(3)) // 3-day hold
->whereHas('orders', fn($q) => $q->where('payout_status', 'held'))
->get();
foreach ($completedEvents as $event) {
$payoutService->releaseEventPayouts($event);
$this->info("Released payouts for: {$event->title}");
}
}
}
Refund Handling
// app/Services/RefundService.php
class RefundService
{
public function processRefund(Order $order, string $reason): void
{
$stripe = new StripeClient(config('services.stripe.secret'));
// Full refund if event hasn't happened
if ($order->event->starts_at > now()) {
$stripe->refunds->create([
'payment_intent' => $order->stripe_payment_intent_id,
'reason' => 'requested_by_customer',
'metadata' => ['reason' => $reason],
]);
$order->update(['status' => 'refunded', 'payout_status' => 'cancelled']);
// Return ticket inventory
foreach ($order->tickets as $ticket) {
$ticket->ticketType->decrement('quantity_sold');
$ticket->delete();
}
event(new OrderRefunded($order));
}
}
}
Conclusion
Event platforms with Stripe Connect and escrow protect both organizers and attendees. Automated payout release after event completion ensures trust and reduces manual intervention.
Building an event platform? Contact ZIRA Software for ticketing platform development.