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.
- 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
Engineering Evidence
- 113 Pest test cases (opens in a new tab)
- PHPStan level 8, Laravel Pint, and the Pest suite run in GitHub Actions CI (opens in a new tab)
- 27 REST endpoints, documented in an OpenAPI 3.1 spec generated from the code with Scramble and published as interactive API documentation (opens in a new tab)
- Architecture document with three diagrams: system context, data model, and the offer state machine (opens in a new tab)
- Five Architecture Decision Records (opens in a new tab)
- A k6 load-test script and seed command for the offer-create endpoint (tooling only; no performance claim is made) (opens in a new tab)
Counts below were checked against the public repository (commit 73f9bad) on November 5, 2026 and may change as it evolves.
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
- Cascade: listing sold, other Sent offers rejected, withdrawn offers untouched (AcceptOfferCascadeTest (opens in a new tab))
- Four legal and four blocked transitions (OfferStateMachineTest (opens in a new tab))
- Only the listing's organizer can accept a Sent offer (OfferPolicy (opens in a new tab))
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
| Practice | What the repository shows | What it does not show |
|---|---|---|
| Automated tests | 113 Pest tests (opens in a new tab) passed in CI at this commit (361 assertions) against MySQL 8.4 | Concurrency, rollback, or a coverage figure |
| Static analysis and CI | PHPStan 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 passed | Race conditions, or any deployment of the API |
| API documentation | 27 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 records | Five ADRs (opens in a new tab) and an architecture document (opens in a new tab) with three diagrams | That the code follows them, as the boundary rule above shows |
| Reproducible setup | Docker Compose (opens in a new tab) stack, Makefile, committed composer.lock, and .env.example | The 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.