Live reverse auction on Symfony: anti-sniping, Redis and Mercure SSE
1. Introduction: why a live reverse auction is hard
An online reverse auction looks simple: participants bid below the current price, the lowest bid wins. But once the auction goes live — with a countdown timer everyone sees and bids that must reach every participant instantly — the simple task turns into a bundle of four non-trivial problems.
Realtime. Every bid must not only be recorded but also reach all connected clients within fractions of a second. Classic synchronous request-response does not work here: the server cannot “call” the client. You need push.
Races. Two bids at the same price placed simultaneously — who wins? If “both”, the auction is broken. If “neither” — broken too. The bid, the price, and the timer must change atomically, otherwise an observer sees a price that never existed.
Money. A bid is a commitment. The price must not “drift” because of rounding or operation ordering: an auction deals with money, and the arithmetic here is checked as strictly as in a payment core.
Trust. Participants see the timer. If it “jumps” or someone managed to bid after closing — the auction loses legitimacy and the platform loses reputation. Anti-sniping — a bid in the final window extends the trading — is not an option but a mandatory rule.
This article is a breakdown of how these four problems are solved in the open-source project tender (PHP 8.5 / Symfony 8.1, PostgreSQL 17, Redis 7, RabbitMQ 4, Mercure, MIT license). The project is a modular monolith: tenders, auctions, contracts, guarantees, integrations. Here I walk through the hottest module — Auction: the domain and state machine, anti-sniping, the transactional bid path, live push via Mercure SSE, and crash recovery. Every number comes from the repository and its load tests.
2. Domain: three auction types and a state machine with 16 statuses
An auction in the project is not a single entity but three scenarios differing in pricing rules (AuctionTypeEnum):
- REDUCTION — a classic reverse auction with two-step modes:
step_mode=fixed(a step from the starting price; a bid must be at least one step below the current price) andstep_mode=free(any price below the current one, floor atprice_min_limit_minor). - FREE_PRICE — no mandatory decrease: any bid is accepted,
current_pricetracks the best (lowest) offered price. - PRICE_REQUEST — a “price request” without live steps: the window closes at
planned_end_at, no extensions.
The auction lifecycle is described by a full symfony/workflow (config/workflow/auction.yaml): 16 persistent statuses (plus a virtual CREATED that never appears in the DB) and 38 transitions — 23 named, the rest are multi-from edges of common transitions (CANCEL — from 10 statuses, CONFIRM_DONE and CLAIM — from 3, EXPIRE — from 3).
Key state machine decisions:
Statuses are enum cases, not strings. Places and transitions reference AuctionStatusEnum::…->value via !php/enum. Renaming a string is a compile error, not a “magic string lost in production”.
A guard on entering trading. The SCHEDULED → TRADE transition (start_trade) is allowed only with a frozen snapshot of the rules: before the start, the service calls Auction::captureRulesSnapshot(), and the guard checks subject.getRulesSnapshot() !== null. An auction cannot start with “half-rules” — the rules snapshot (RulesSnapshot) is captured once and survives the entire trading period.
!php/enum App\Auction\Entity\Enum\AuctionStatusTransition::START_TRADE->value:
from:
- !php/enum App\Auction\Entity\Enum\AuctionStatusEnum::SCHEDULED->value
to: !php/enum App\Auction\Entity\Enum\AuctionStatusEnum::TRADE->value
guard: "subject.getRulesSnapshot() !== null"
This snapshot is not an implementation detail but the foundation for the next two sections: anti-sniping and the bid transaction read their parameters (step, window, extension limits) only from the snapshot, never from “live” settings that could change mid-trading.
3. Anti-sniping: a timer that cannot be cheated
The rule is simple: a bid placed in the final window (within step_duration_sec of planned_end_at) extends the auction by extension_duration_sec. Without this rule, the auction becomes a “who clicks fastest in the last second” lottery — sniping.
The implementation — AuctionTimer::extendOnBid(), pure domain logic with no dependencies:
public function extendOnBid(
\DateTimeImmutable $now,
\DateTimeImmutable $plannedEndAt,
int $extensionsCount,
RulesSnapshot $snapshot,
?\DateTimeImmutable $executionStartAt,
): ?\DateTimeImmutable {
if (!$snapshot->extendOnLastStep) {
return null; // plugin rule: no extension
}
// A bid outside the last window — no extension needed.
if ($now->getTimestamp() <= $plannedEndAt->getTimestamp() - $snapshot->stepDurationSec) {
return null;
}
// Extension limit exhausted.
if ($extensionsCount >= $snapshot->maxExtensions) {
return null;
}
$candidate = $plannedEndAt->add(new \DateInterval('PT'.$snapshot->extensionDurationSec.'S'));
// Hard boundary: trading cannot run past execution_start − lead_hours.
$cutoff = $this->tradeEndCutoff($executionStartAt, $snapshot->tradeEndLeadHours);
if (null !== $cutoff) {
if ($candidate > $cutoff) {
$candidate = $cutoff; // clamp to the limit
}
if ($candidate <= $now) {
return null; // boundary passed — forbid
}
}
return $candidate;
}
Note the three independent safeguards:
- The window. Only a bid in the final window (
step_duration_sec) extends the auction, not any bid. This keeps trading predictable rather than “endless”. - The limit.
max_extensionscaps the number of extensions per auction — protection against infinite trading. - The boundary.
trade_end_lead_hours— trading physically cannot end later than N hours before the lot’s execution starts. If the clamped boundary has already passed, the extension is forbidden: “clamp to the limit or forbid”.
And all of it comes from the RulesSnapshot, frozen before the start (section 2). A platform admin can change settings at any moment — a running auction will not see it.
4. The hot path: one transaction, one flush, one winning bid
The heart of the system is POST /auctions/{id}/bids. The path: validation by auction type in the service, then BidTransaction::commitBid() inside an active transaction. Key decisions:
Pessimistic lock on the auction row. Before the read-modify-write, the service takes the auction row in SELECT … FOR UPDATE (LockMode::PESSIMISTIC_WRITE). Two parallel bids are serialized on this lock: the second waits until the first commits and reads the already-updated price. Hence, the invariant: two bids at the same price — exactly one wins. An append-only bonus: auction_bids is append-only, the bid history is never overwritten.
One flush per bid. A single batch writes: the bid (INSERT auction_bids), the updated price (UPDATE current_price_minor), the arithmetic audit (before/after for price, timer, extension count — no separate flush), and the outbox event auction.bid. One EntityManager::flush() per bid — minimal write round-trips. This is a direct NFR requirement: the target is 100–200 bids/sec.
Money as integers. Prices are stored in minor units (price_minor, bps percentages for VAT) — no floats. The arithmetic audit (PR-9) records before/after of every mutation: if a price “jumps” incorrectly, it is visible in the audit, not in an argument with a participant.
Idempotency. Client Idempotency-Key plus a replay check under the same pessimistic lock (replayBid): a redelivered request returns the already-accepted bid, no duplicate is created. The unique index (auction_id, idempotency_key) is the second line of defense in case the application errs.
Redis — after commit. The live state snapshot (AuctionStateService) is written after the DB transaction commits, outside it. The snapshot is a read cache and the data source for push — not part of the consistent store. If Redis goes down, the source of truth (PostgreSQL) is intact, and the snapshot is rebuilt (section 6).
5. Live push: Redis → outbox → RabbitMQ → Mercure SSE
Now, how a bid reaches all participants. The publication flow is “from the core”, strictly async, with four links:
What matters here:
php-fpm holds no connections. SSE connections live on a separate hub (Go/Mercure) that scales independently of the PHP pool. A PHP worker writes the bid, commits, publishes the event — and is free. The classic “SSE on php-fpm” mistake (workers stuck on open connections) is avoided architecturally: PHP does not participate in delivery at all.
Private topic. The topic is auction:{id} (AuctionTopic). Subscription requires a JWT carrying the sub right and the auction ID: admitted participants, the client, observers. Publication requires a JWT with the publish right, held only by the core. A random client cannot subscribe to someone else’s auction: the hub rejects the connection before PHP sees a single request.
Data from Redis, not from the DB. The consumer AuctionStreamPublisher::publishFromEvent() reads the Redis snapshot (AuctionStateSnapshot.lastBid*), which already contains the latest bid and timer. The DB is not read on the delivery path at all — otherwise every participant click would become an extra query to PostgreSQL. Event types: state (snapshot on connect), bid, status, timer.
Outbox — delivery guarantee. The event is written in the same transaction as the bid. Either the bid and the event, or nothing: losing a “bid without event” is impossible by construction. The RabbitMQ consumer with retries and a dead-letter queue covers the remaining scenarios (hub unavailable, network blip).
6. Crash recovery: lose Redis — the auction survives
Live state lives in Redis, but the source of truth is PostgreSQL. So a Redis failure does not crash the auction — it moves it into a managed mode:
- Heartbeat. While trading runs, live state is refreshed with a heartbeat. If there are no updates for longer than
AUCTION_HEARTBEAT_TIMEOUT— the auction is automatically paused (theTRADE → PAUSEDworkflow transition). Participants do not trade “blind” against a dead timer. - Remaining seconds — in PostgreSQL. On pause, the remaining timer value is persisted to the DB; on resume (
RESUME), the countdown continues from the remainder, not from zero. A pause does not steal a single second from participants. - Rebuild from PG. The commands
auctions:recoverandauctions:state:rebuildrebuild the live snapshot from PostgreSQL: latest bid, current price, timer, status. After Redis recovers, publications continue from a correct state.
The scenario “Redis died mid-trading” is not an emergency requiring manual investigation but a routine transition: pause → rebuild → resume. For an auction dealing with money, this is the difference between “trading stopped, everyone sees why” and “someone made it, someone didn’t”.
7. Pitfalls: APP_DEBUG and php-fpm max_children
Three findings from the load tests (load/README.md) that deserve their own section, because each one is a potential incident.
Mercure 2.x broke legacy claims. The hub image is pinned to v0.16.3 (the last 0.x), not latest. Reason: Mercure 2.x moved authorization to OAuth2 authorization_details (RFC 9396) with typ: at+jwt and mandatory iss/aud — while the legacy mercure.publish/subscribe claims generated by symfony/mercure 0.7.x do not work on a v2 hub (publish → 401). If you simply update the image “to the latest”, all auction SSE streams die silently.
Publish format 0.16+. In 0.16, the topic/data parameters are sent in the form body (application/x-www-form-urlencoded), not in the query string — otherwise you get 400 “Missing topic parameter”. And the publish claim in the JWT must be exactly ['*']: a glob subscription load:* → 401.
APP_DEBUG=1 adds ~300–400 ms per request (container compilation, profiler). On a dev environment this makes the SLO (p95 < 100 ms) unreachable even on a catalog of 100 rows (p95 ~600 ms). Load runs use APP_DEBUG=0 — the “prod-compilation” profile with the same application logic.
php-fpm max_children. The php:8.5-fpm default is 5 workers: ~15–20 bids/sec and p95 ~700 ms at 20 VU. The load profile mounts max_children=30: ~22 bids/sec, p95 ~550 ms, 100% accept rate. A takeaway worth keeping in mind for any PHP load measurement: check the worker pool first, then read the application metrics.
Webhook delivery — a dedicated worker. A shared messenger:consume across all transports cycles through queues, and empty RabbitMQ/Redis queues slow down webhook task pickup (~10 deliveries/sec, burst tail grows by tens of seconds). Webhook delivery moved to a dedicated webhooks worker (messenger:consume webhooks): ~1200+ events/min, p95 ~2.7 s. For the < 5 s delay SLO on a burst you need a steady-state event emitter — a single burst still leaves a tail.
8. Numbers and SLOs
The load scenarios (k6 for HTTP, node for the SSE hub — stock k6 cannot read an SSE stream, and xk6-sse is deprecated) run against the dev docker-compose stack; the goal of each scenario is green thresholds:
| Scenario | SLO | Actual on dev |
|---|---|---|
| Bids (domain write path) | p95 < 100 ms, 100–200 bids/sec (stage 1) | p95 ~9–13 ms, ≥30 bids/sec (smoke) |
| Bids (HTTP e2e) | bid_write_ms p(95) < 1000 |
~22 bids/sec, p95 ~550 ms at max_children=30 |
| Catalog | p95 < 200 ms | p95 ~166 ms at ~100 published / 5000 total (3 VU) |
| SSE discovery | p95 < 1 s | in the run report |
| SSE delivery to client | p95 < 1 s | in the run report (node script, N subscribers) |
| Webhooks | ≥10,000 events/min, delay < 5 s | ~1200+/min on a dedicated worker, p95 ~2.7 s (dev scale) |
The gap between “domain write path p95 9–13 ms” and “HTTP e2e ~22 bids/sec” is the cost of transport: serialization, the php-fpm pool, the network. Both numbers are honest, and both are needed: the first speaks to the quality of the domain logic, the second to where the bottleneck is when scaling (the worker pool, not the transaction).
9. Takeaways
A live reverse auction in PHP is a realistic task if you follow four rules:
- State is a state machine, not a set of ifs: 16 statuses, 38 transitions, enum cases instead of strings, guards on critical transitions.
- The hot path is one transaction under a pessimistic lock with a single flush: bid, price, audit, and outbox are atomic; Redis is a post-commit cache, not the source of truth.
- Push goes through a dedicated hub (Mercure SSE), not php-fpm: private topics with JWT, data from the Redis snapshot, outbox guarantees “bid → event”.
- A failure is a managed transition: heartbeat, auto-pause with the timer remainder in PG, live-state rebuild from PostgreSQL.
And remember the pitfalls: APP_DEBUG in load tests is a mine, and a default php-fpm pool of 5 workers is not a “slow application” — it’s a configuration.
The project is open source: github.com/alex-frolov/tender, MIT. Load scenarios, the state machine, and all docs are in the repository. The series continues with the modular monolith and PHPArkitect, sealed bids with encryption until opening, and an outbox with 63 JSON Schema events.
Follow me: frolov.guru — articles on high-load PHP, architecture, and engineering. Telegram: @frolov_aleksander. Questions and objections about auction architecture — in the repository issues, I reply.