Skip to main content
Back to Projects

Personal project · public source

Pulsepass API

A Laravel 12 backend for a ticket resale platform, built to show how I structure and test a non-trivial API. The interesting part is not the CRUD but what happens when a seller accepts an offer: the offer moves through a guarded state machine, the listing is marked sold, and every competing offer is rejected inside a single database transaction. Around that core sit a scheduled Ticketmaster sync, Stripe-gated listing quotas, and a Stripe webhook handler that skips events it has already recorded.

Personal portfolio project, not a deployed commercial product. The source code, tests, CI configuration, and API documentation are public.

Sole developer (personal portfolio project)
  • Laravel 12
  • PHP 8.4
  • MySQL
  • Redis
  • Stripe Cashier
  • Pest
  • PHPStan
  • OpenAPI 3.1
  • GitHub Actions

Key value: Shows how I model a transactional marketplace workflow with clear domain boundaries, guarded state transitions, and tests that run in CI.

What I Built

  • Organized the code as a modular monolith: six folders under app/Domain (five business domains plus Auth), with thin HTTP controllers and form requests per domain
  • Modeled offers as a state machine (Sent to Accepted, Rejected, Withdrawn, or Expired) with guarded transitions using spatie/laravel-model-states
  • Implemented offer acceptance as one transaction: the offer transitions, the listing is marked sold, and all other pending offers on that listing are rejected
  • Built a scheduled Ticketmaster sync that rate-limits requests and commits each event in its own transaction, so one bad event does not abort the batch
  • Integrated Stripe subscriptions through Laravel Cashier and enforced per-tier listing quotas (Free: 1, Pro: 10, Festival: unlimited) at listing creation
  • Tracked Stripe webhook event IDs, so a redelivered event that was already recorded is acknowledged without being processed again
  • Authenticated the API with Laravel Sanctum personal access tokens and authorized actions with policies

Architecture Decisions

  • Domain-oriented layout (Billing, Events, Listings, Offers, and Organizer, plus an Auth folder) keeps business rules free of HTTP concerns, with controllers acting as thin adapters
  • A state-machine library replaces a status enum, so illegal transitions are rejected in one place (ADR 0002)
  • Per-event transactions in the sync trade all-or-nothing atomicity for partial-success resilience (ADR 0003)
  • Sanctum personal access tokens were chosen over JWT (ADR 0004), and plain Action classes over an action package (ADR 0005)

Technical Challenges

  • Keeping offer acceptance atomic: transitioning one offer, closing the listing, and rejecting sibling offers without leaving a partial state if any step fails
  • Handling Stripe webhook retries: recording event IDs to detect redeliveries, and understanding where that stops short of full idempotency under concurrent deliveries
  • Syncing third-party event data in batches without letting a single malformed record block the rest

Backend Architecture & Reliability Decisions

Pulsepass is a portfolio project, not a deployed product, so these notes explain the reasoning and stay explicit about limits, with no claims about production behavior. Source links point to commit 73f9bad of the public repository, which I reviewed on November 5, 2026.

Why a modular monolith?

A modular monolith is one deployable application divided into modules by business capability. Pulsepass keeps its core logic in app/Domain (opens in a new tab): five business domains (Billing, Events, Listings, Offers, Organizer) plus an Auth folder that ADR 0001 (opens in a new tab) treats as scaffolding. Controllers only translate HTTP into calls to single-purpose action classes, so a rule such as accepting an offer can run from a controller, a scheduled command, or a test.

Microservices are not automatically better. They add network boundaries and distributed transactions, and ADR 0001 notes that rejecting sibling offers on accept is simple in one database and painful across services.

What the structure gives

  • Changes to one capability stay in one folder
  • Business rules do not depend on HTTP
  • Multi-step changes can share one transaction

Trade-offs and limits

  • One deployable unit: no independent deployment or fault isolation between modules
  • Boundaries are convention only: nothing enforces the ADR's rule that cross-domain calls use public APIs, and Offers code uses the Listings model directly

Transactions and state machines: accepting an offer

Suppose Ana and Ben both have offers on one listing and the seller accepts Ana's. Three writes must go together: Ana's offer becomes Accepted, the listing becomes Sold, and Ben's offer becomes Rejected. Anything less leaves contradictory data.

AcceptOffer (opens in a new tab) runs those writes in one DB::transaction, so a database error partway through rolls them all back. A state machine adds a second guard: OfferState (opens in a new tab) lets an offer leave Sent only for Accepted, Rejected, Withdrawn, or Expired; any other move throws TransitionNotFound.

Implemented and tested

Not implemented or not tested

  • Rollback after a mid-transaction failure is untested
  • No row locks; offer state is not re-read inside the transaction
  • No test sends overlapping requests

Atomicity is not concurrency safety. A transaction makes one request's writes all-or-nothing; it does not stop overlapping requests from acting on stale state. The policy checks Sent before the transaction opens, so two near-simultaneous accepts on different offers for one listing could both pass, and the outcome would depend on timing. The current code does not prevent this.

Safeguards I would add (not implemented)

  • Lock the listing row at the start of the transaction, then re-check listing and offer state
  • Make the update conditional (UPDATE … WHERE status = 'sent') and treat zero affected rows as a conflict, returning 409
  • Add a test that runs two accepts against one listing

Reliable webhook processing

Payment providers retry webhook deliveries because networks fail: a timeout or error makes the sender resend, and a delivery that succeeded can still look failed. A handler must expect the same event more than once, because repeating a side effect such as granting access can corrupt state.

WebhookController (opens in a new tab) returns 200 if the Stripe event ID is already in the webhook_events table (unique index), otherwise runs Cashier's handler and then records the ID. If the handler throws, nothing is recorded, so Stripe's retry runs it again. StripeWebhookTest (opens in a new tab) checks that a second delivery returns 200 and leaves one row.

That is replay detection: it recognizes events already recorded. Stronger idempotency means an overlapping second attempt cannot repeat the effect. This code does not provide that, so it is not exactly-once processing.

Where it falls short

  • Check, processing, and recording are separate steps, not one atomic unit
  • Two simultaneous deliveries can both pass the check and both run the handler; the unique index only fails the second insert afterwards
  • A crash between the handler and the insert causes reprocessing on retry
  • No test sends concurrent deliveries

Stronger designs (not implemented)

  • Claim the event ID first with a unique insert, in the same transaction as the handler's database changes
  • Make handlers idempotent by design, such as setting state from the event instead of incrementing
  • Store a processing status to detect interrupted attempts

Engineering quality and what it shows

Practices in the Pulsepass repository, and what each does not show
PracticeWhat the repository showsWhat it does not show
Automated tests113 Pest tests (opens in a new tab) passed in CI at this commit (361 assertions) against MySQL 8.4Concurrency, rollback, or a coverage figure
Static analysis and CIPHPStan level 8, Pint, and the test suite (opens in a new tab) run as separate GitHub Actions jobs (opens in a new tab) on every push to main and every pull request; the latest run passedRace conditions, or any deployment of the API
API documentation27 operations in an OpenAPI 3.1 spec generated with Scramble, published as interactive docs (opens in a new tab)The business rules behind the contract
Decision recordsFive ADRs (opens in a new tab) and an architecture document (opens in a new tab) with three diagramsThat the code follows them, as the boundary rule above shows
Reproducible setupDocker Compose (opens in a new tab) stack, Makefile, committed composer.lock, and .env.exampleThe Docker setup is not exercised in CI

Related Projects

Interested in working together?

I'm open to full-time international remote roles and long-term contracts.

Get in Touch