DEV3 Technologies home

Anti-sniping auction extension

Extend an auction's end time when a bid lands in the final minutes, with a cap on total extensions so it cannot run forever.

LaravelUtilitiesauctiontimingcarbonrules

Code

<?php

namespace App\Auctions;

use Carbon\CarbonImmutable;

/**
 * "Soft close": a bid inside the last $windowSeconds pushes the end time out
 * so rivals get a fair chance to answer. Pure, so it is trivial to unit test.
 */
final class AntiSnipe
{
    public function __construct(
        private readonly int $windowSeconds = 120,
        private readonly int $extendSeconds = 120,
        private readonly int $maxExtensions = 10,
    ) {}

    /**
     * @return array{ends_at: CarbonImmutable, extended: bool, extensions: int}
     */
    public function apply(CarbonImmutable $endsAt, int $extensionsUsed, ?CarbonImmutable $bidAt = null): array
    {
        $bidAt ??= CarbonImmutable::now();
        $remaining = $bidAt->diffInSeconds($endsAt, false); // negative once closed

        $inWindow = $remaining >= 0 && $remaining <= $this->windowSeconds;

        if (! $inWindow || $extensionsUsed >= $this->maxExtensions) {
            return ['ends_at' => $endsAt, 'extended' => false, 'extensions' => $extensionsUsed];
        }

        // Reset the clock to a full extension from the bid, never shorten it.
        $candidate = $bidAt->addSeconds($this->extendSeconds);

        return [
            'ends_at' => $candidate->greaterThan($endsAt) ? $candidate : $endsAt,
            'extended' => $candidate->greaterThan($endsAt),
            'extensions' => $extensionsUsed + 1,
        ];
    }
}

How it works

What it solves

Sniping means placing a bid a second before the auction closes so nobody can respond. Marketplaces counter it with a soft close: a late bid extends the deadline. The rule is easy to describe but has awkward edges, such as bids after the close, repeated extensions and clock comparisons, so it deserves its own tested class.

How it works

apply() receives the current end time, how many times the auction has already been extended and the time of the bid. It computes the signed number of seconds remaining. A negative value means the auction has already closed, so nothing changes.

If the remaining time is inside the configured window (two minutes by default) and the extension counter is below the cap, the new end time becomes the bid time plus the extension length. The code never moves the end earlier: if the candidate is not later than the existing end, the original is kept and extended is false. The array returned contains the new end time, whether it changed and the updated counter, so the caller can persist all three and tell clients to reset their countdown.

The class holds no database access and uses immutable dates, which makes tests a matter of passing fixed timestamps.

Adapting it

  • Call it inside the bid transaction, right after the price update, and save ends_at on the same row.
  • Show a notice such as "Time extended by 2 minutes" when extended is true.
  • Load the three settings per category from config.

Pitfalls

  • Use the server clock for $bidAt; never accept a time from the client.
  • Set a maximum number of extensions so a determined pair of bidders cannot keep an auction open indefinitely.

How to use it

  1. Put AntiSnipe.php in app/Auctions/.
  2. Store ends_at (cast to immutable_datetime) and extensions (unsigned int) on the auction.
  3. After a successful bid, inside the same transaction: $r = (new AntiSnipe)->apply($auction->ends_at, $auction->extensions);
  4. Persist it: $auction->update(['ends_at' => $r['ends_at'], 'extensions' => $r['extensions']]);
  5. Broadcast the new end time to viewers when $r['extended'] is true.

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