DEV3 Technologies home

Bid placement with row lock

Place an auction bid inside a transaction that locks the auction row, validates the minimum increment and updates the leader atomically.

LaravelDatabaseauctiontransactionslockingconcurrency

Code

<?php

namespace App\Auctions;

use App\Models\Auction;
use App\Models\Bid;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class BidService
{
    /** Increment grows with the price: [from_amount => step] in cents. */
    private const STEPS = [0 => 100, 5000 => 250, 25000 => 500, 100000 => 2500];

    public function place(int $auctionId, int $userId, int $amount): Bid
    {
        return DB::transaction(function () use ($auctionId, $userId, $amount) {
            // Every bidder queues on this lock, so each sees the true current price.
            $auction = Auction::whereKey($auctionId)->lockForUpdate()->firstOrFail();

            if ($auction->status !== 'live' || $auction->ends_at->isPast()) {
                $this->fail('This auction is closed.');
            }
            if ($auction->seller_id === $userId) {
                $this->fail('You cannot bid on your own listing.');
            }
            if ($auction->leader_id === $userId) {
                $this->fail('You are already the highest bidder.');
            }

            $min = $auction->bid_count === 0
                ? $auction->start_price
                : $auction->current_price + $this->step($auction->current_price);

            if ($amount < $min) {
                $this->fail('Minimum bid is '.number_format($min / 100, 2).'.');
            }

            $bid = Bid::create(['auction_id' => $auction->id, 'user_id' => $userId, 'amount' => $amount]);

            $auction->update([
                'current_price' => $amount,
                'leader_id' => $userId,
                'bid_count' => $auction->bid_count + 1,
            ]);

            return $bid;
        });
    }

    private function step(int $price): int
    {
        $step = self::STEPS[0];
        foreach (self::STEPS as $from => $s) {
            if ($price >= $from) {
                $step = $s;
            }
        }

        return $step;
    }

    private function fail(string $message): never
    {
        throw ValidationException::withMessages(['amount' => $message]);
    }
}

How it works

What it solves

Auctions are a textbook race condition. If two people bid at the same instant, both requests read the same current price, both pass validation and the later write silently overwrites the earlier one. The page then shows a leader whose bid is lower than another stored bid. A pessimistic lock on the auction row removes that whole class of bug.

How it works

Everything runs in a database transaction that begins by loading the auction with lockForUpdate(). The second bidder's request pauses at that line until the first transaction commits, and then reads the already-updated price, so each validation sees the true state.

The checks are explicit: the auction must be live and not past its end time, sellers cannot bid on their own item, and the current leader cannot outbid themselves. The minimum is the start price for the first bid, then the current price plus a step taken from a banded table, so the increment grows with the price. Amounts are integers in cents.

After inserting the bid, the auction row's price, leader and counter are updated in the same transaction. Failures use a typed never helper that throws a ValidationException, giving a clean 422 for APIs.

Adapting it

  • Dispatch a broadcast with DB::afterCommit so clients update only after the bid is durable.
  • Add proxy (maximum) bidding by storing a ceiling and raising the price automatically.
  • Keep the step table in the database for each category.

Pitfalls

  • Keep the transaction short; do not call external services inside it.
  • SQLite ignores row locks, so load-test on MySQL or PostgreSQL.

How to use it

  1. Create auctions (start_price, current_price, leader_id, bid_count, seller_id, status, ends_at) and bids tables with integer cent amounts.
  2. Put BidService.php in app/Auctions/.
  3. Call it from a controller: $bid = app(BidService::class)->place($auction->id, $request->user()->id, $request->integer('amount'));
  4. Return the bid as JSON; validation errors become 422 responses automatically.
  5. Broadcast the update after commit.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets