Home / Articles / Web Development
Web Development

Button Clicked Twice? How to Prevent Duplicate Orders in PHP and MySQL

Duplicate orders do not always occur due to careless users. Slow networks, buttons that have not been disabled, or requests that are resent can cause one action to be recorded twice. With an idempotency key, unique...

Tombol Bayar Diklik Dua Kali? Cara Mencegah Pesanan Dobel di PHP dan MySQL

One click should result in one order. However, in web applications, this does not always happen. Users may click the pay button twice, the browser may repeat requests when the connection is unstable, or the payment service may send webhooks more than once.

If the endpoint simply executes INSERT every time it receives a request, the result could be two orders, two stock reductions, or two payment records. Disabling the button in JavaScript does help, but it is not the primary protection. Requests can still be resent from the browser, mobile applications, proxies, or external systems.

A stronger pattern is called idempotency: the same request can be processed multiple times, but the final effect occurs only once.

Idempotency with a Simple Analogy

Imagine you submit a payment form with a unique reference number. The officer may receive that form multiple times, but upon seeing the same reference number, they know that the form has already been processed.

In applications, that reference number is called an idempotency key. This key is created by the client or server before the important process begins and is sent along with the request. For example:

  • order-8f3a... for order creation.
  • payment-2026-00091 for payment.
  • Event ID from the payment provider for webhooks.

This key must be stored in the database with unique constraints. Do not just check using SELECT and then perform INSERT, as two requests arriving almost simultaneously could both pass the check.

Do Not Rely Solely on JavaScript

On the interface side, the button can be disabled after being clicked to prevent users from accidentally sending repeated requests. This is good for user experience, but not a security mechanism.

JavaScript can fail to load, the browser can retry, and requests can be sent directly without going through the page you created. Therefore, duplication prevention must be on the server and database side.

The database is in a better position to enforce the rule of “one key may only appear once.” In MySQL, create an idempotency key column with a UNIQUE index:

CREATE TABLE orders (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    idempotency_key VARCHAR(100) NOT NULL,
    status ENUM('pending', 'paid', 'failed') NOT NULL DEFAULT 'pending',
    total DECIMAL(12,2) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uq_orders_idempotency (idempotency_key)
) ENGINE=InnoDB;

With this rule, two requests with the same key cannot create two order rows. One request will succeed, while the next request must be treated as a repetition of an existing operation.

Combine with Transactions

Transaction is a set of database changes treated as a single unit. If the entire process succeeds, the application executes COMMIT. If there is a failure, changes can be rolled back with ROLLBACK.

In PHP, PDO provides beginTransaction(), commit(), and rollBack(). Transactions are suitable when order creation involves multiple steps, such as saving the order, saving product details, and reducing stock.

<?php
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$key = $_POST['idempotency_key'] ?? '';
$userId = 17;
$total = 149000;

if ($key === '' || strlen($key) > 100) {
    http_response_code(422);
    exit('Invalid idempotency key');
}

try {
    $pdo->beginTransaction();

    $insert = $pdo->prepare(
        'INSERT INTO orders (user_id, idempotency_key, total)
         VALUES (:user_id, :key, :total)'
    );
    $insert->execute([
        ':user_id' => $userId,
        ':key' => $key,
        ':total' => $total
    ]);

    $orderId = $pdo->lastInsertId();

    // Save order details and reduce stock here.

    $pdo->commit();
    echo json_encode([
        'order_id' => $orderId,
        'status' => 'created'
    ]);
} catch (PDOException $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    // SQLSTATE 23000 generally indicates a constraint violation.
    if ($e->getCode() === '23000') {
        $find = $pdo->prepare(
            'SELECT id, status FROM orders WHERE idempotency_key = :key'
        );
        $find->execute([':key' => $key]);
        $existing = $find->fetch(PDO::FETCH_ASSOC);

        if ($existing) {
            echo json_encode([
                'order_id' => $existing['id'],
                'status' => $existing['status'],
                'duplicate' => true
            ]);
            exit;
        }
    }

    http_response_code(500);
    echo json_encode(['error' => 'Order failed to process']);
}

This example uses prepared statements to ensure that request values are not directly appended to SQL. The implementation details need to be adjusted according to the application structure, but the idea is important: the database decides whether the key has been used before.

Do Not Treat All Duplicate Requests the Same

When the second request arrives, the server needs to know whether that request is truly the same as the first request. Therefore, in addition to storing the key, you can store some additional information:

  • Payload or hash of the request content.
  • Process status, such as pending, paid, or failed.
  • Last response sent to the client.
  • Expiration time of the key if the operation is only valid for a certain period.

If the same key is sent with a different total, do not silently use the new data. Reject the request as there may be a client error or key misuse.

Transactions Are Not a Substitute for Idempotency

Transactions maintain the consistency of multiple changes in one process. However, transactions alone do not automatically prevent the same request from creating two orders. Two requests could each execute transactions and both succeed.

Conversely, a unique constraint alone is also not sufficient if one process consists of many changes. An order may be successfully created, but stock may fail to be reduced. A safer combination is:

  1. Use an idempotency key to identify the same operation.
  2. Use a unique constraint to prevent duplication atomically.
  3. Use transactions to bind related changes.
  4. Handle duplicate errors as a result of repetition, not always as a failure.

Be Aware of Deadlocks and Long Processes

Transactions should be kept short. Do not send emails, call payment APIs, or wait for external processes while the database transaction is still open. The longer a transaction runs, the longer the lock can be held.

MySQL documents that deadlocks can cause entire transactions to be canceled, so the application needs to be ready to retry transactions under certain conditions. Therefore, save core changes as quickly as possible, then run subsequent work through a queue or asynchronous process if necessary.

What Does This Mean for Us?

Order duplication is a flow design issue, not just a problem of buttons being clicked too quickly. Start with operations that directly impact money, stock, quotas, or notification delivery.

Add an idempotency key to those endpoints, set a unique index in the database, and wrap related changes with transactions. After that, test often-forgotten scenarios: double clicks, refreshing during the process, retries due to timeouts, two parallel requests, and resent webhooks.

The documentation on PDO transactions explains the use of commit and rollback, while the documentation on InnoDB discusses duplicate keys, deadlocks, and lock timeouts. Both are worth reading before applying this pattern in a production system.

Sources & Further Reading

– Rio Yotto @rioyotto