Can AI Fix Production PHP Without Creating New Problems? We gave the same vulnerable PHP checkout to ChatGPT, Gemini, Grok, Perplexity and local AI models. The real test was whether they could fix it without leaving dangerous problems behind.
Can AI Fix Production PHP Without Creating New Problems?
Yesterday I ran an experiment about AI visibility and how different models reconstruct information. Today I wanted to test something completely different: code.
But I didn't want another coding benchmark where an AI is asked to create a function, solve an algorithm or build a small application from scratch. We already know modern models can produce impressive code under those conditions.
I wanted something closer to what actually happens in production.
Real production code is rarely completely broken. More often, it works. It may have been running for months or years while hiding security problems, questionable architectural decisions and bugs that only appear under specific conditions. The difficult part isn't always writing better code. Sometimes it is understanding why apparently reasonable code can fail.
So I created a small PHP shopping-cart and checkout script specifically for this experiment.
The script runs. There are no deliberate syntax errors preventing it from executing, and its intended behavior is easy to understand. A user can add products to a cart, calculate the total, apply a coupon, search for products and complete a checkout.
Underneath that ordinary-looking code, however, I deliberately left several problems involving security, business logic, database access and concurrency.
Then I gave exactly the same code to ChatGPT, Gemini, Grok, Perplexity and three smaller local models: Gemma 3 4B, Meta Llama 3.1 8B and Qwen 3 8B.
No model was told how many problems existed. They received no hints about where those problems were located and no checklist of vulnerabilities to search for.
They all received exactly the same prompt.
The Prompt
“This PHP code runs, but it contains several bugs, security problems and at least one architectural trap.
Review it as if it were production code.
Find every problem you can, explain why each one matters, and return a corrected version.
Do not rewrite the application from scratch. Preserve its intended behavior.
Also identify any fix that looks obvious but could still leave the application vulnerable under concurrent requests.”
That final sentence was deliberate.
Finding SQL injection in PHP code isn't a particularly difficult test for a modern coding model. If user input is concatenated directly into an SQL query, I expect an AI reviewing production code to notice it. The same applies to obvious XSS risks, hardcoded credentials or inefficient database access.
Those things matter, but I wanted to know whether the models could go beyond recognizing familiar patterns.
The real trap was concurrency.
The PHP Code
<?php
declare(strict_types=1);
session_start();
$pdo = new PDO(
'mysql:host=localhost;dbname=shop;charset=utf8mb4',
'shop_user',
'shop_password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function jsonResponse(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function getCartTotal(PDO $pdo, int $userId): float
{
$items = $pdo->query("
SELECT product_id, quantity
FROM cart_items
WHERE user_id = $userId
")->fetchAll();
$total = 0;
foreach ($items as $item) {
$product = $pdo->query("
SELECT id, name, price, stock
FROM products
WHERE id = {$item['product_id']}
")->fetch();
if (!$product) {
continue;
}
$total += $product['price'] * $item['quantity'];
}
return $total;
}
if (!isset($_SESSION['user_id'])) {
jsonResponse(['error' => 'Authentication required'], 401);
}
$userId = (int) $_SESSION['user_id'];
$action = $_GET['action'] ?? 'cart';
if ($action === 'add') {
$productId = (int) ($_POST['product_id'] ?? 0);
$quantity = (int) ($_POST['quantity'] ?? 1);
$product = $pdo->query("
SELECT id, name, price, stock
FROM products
WHERE id = $productId
")->fetch();
if (!$product) {
jsonResponse(['error' => 'Product not found'], 404);
}
if ($quantity < 1) {
jsonResponse(['error' => 'Invalid quantity'], 422);
}
if ($product['stock'] < $quantity) {
jsonResponse(['error' => 'Not enough stock'], 409);
}
$existing = $pdo->query("
SELECT id, quantity
FROM cart_items
WHERE user_id = $userId
AND product_id = $productId
")->fetch();
if ($existing) {
$newQuantity = $existing['quantity'] + $quantity;
$pdo->exec("
UPDATE cart_items
SET quantity = $newQuantity
WHERE id = {$existing['id']}
");
} else {
$pdo->exec("
INSERT INTO cart_items (user_id, product_id, quantity)
VALUES ($userId, $productId, $quantity)
");
}
jsonResponse([
'success' => true,
'message' => $product['name'] . ' added to cart'
]);
}
if ($action === 'checkout') {
$coupon = $_POST['coupon'] ?? null;
$items = $pdo->query("
SELECT *
FROM cart_items
WHERE user_id = $userId
")->fetchAll();
if (!$items) {
jsonResponse(['error' => 'Cart is empty'], 422);
}
$total = getCartTotal($pdo, $userId);
if ($coupon) {
$couponRow = $pdo->query("
SELECT *
FROM coupons
WHERE code = '$coupon'
AND active = 1
")->fetch();
if ($couponRow) {
if ($couponRow['type'] == 'percentage') {
$total -= $total * ($couponRow['value'] / 100);
} else {
$total -= $couponRow['value'];
}
}
}
if ($total < 0) {
$total = 0;
}
try {
$pdo->beginTransaction();
foreach ($items as $item) {
$product = $pdo->query("
SELECT id, name, price, stock
FROM products
WHERE id = {$item['product_id']}
")->fetch();
if (!$product || $product['stock'] < $item['quantity']) {
throw new RuntimeException('Product unavailable');
}
$newStock = $product['stock'] - $item['quantity'];
$pdo->exec("
UPDATE products
SET stock = $newStock
WHERE id = {$product['id']}
");
}
$pdo->exec("
INSERT INTO orders (user_id, total, status)
VALUES ($userId, $total, 'paid')
");
$orderId = (int) $pdo->lastInsertId();
foreach ($items as $item) {
$product = $pdo->query("
SELECT price
FROM products
WHERE id = {$item['product_id']}
")->fetch();
$pdo->exec("
INSERT INTO order_items
(order_id, product_id, quantity, price)
VALUES (
$orderId,
{$item['product_id']},
{$item['quantity']},
{$product['price']}
)
");
}
$pdo->exec("
DELETE FROM cart_items
WHERE user_id = $userId
");
$pdo->commit();
jsonResponse([
'success' => true,
'order_id' => $orderId,
'total' => $total
]);
} catch (Throwable $e) {
$pdo->rollBack();
jsonResponse([
'error' => $e->getMessage()
], 500);
}
}
if ($action === 'search') {
$search = $_GET['q'] ?? '';
$products = $pdo->query("
SELECT id, name, description, price
FROM products
WHERE name LIKE '%$search%'
OR description LIKE '%$search%'
LIMIT 20
")->fetchAll();
echo '<h1>Search results for: ' . $search . '</h1>';
foreach ($products as $product) {
echo '<h2>' . $product['name'] . '</h2>';
echo '<p>' . $product['description'] . '</p>';
echo '<strong>€' . $product['price'] . '</strong>';
}
exit;
}
jsonResponse([
'total' => getCartTotal($pdo, $userId)
]);
The Obvious Problems Were Not the Real Test
There are several things I expected the models to find quickly. The coupon and search functionality expose obvious SQL injection risks because input is inserted directly into SQL. Search results are rendered without escaping, creating XSS problems. Database credentials are hardcoded, the cart calculation creates unnecessary queries, and monetary values are handled using floating-point arithmetic.
The business logic has problems too. When a product is added to an existing cart, the stock check considers only the new quantity rather than the quantity already present. A customer could therefore have eight units in the cart, add another five while only ten exist, and pass the initial stock check.
The checkout also reads parts of the application state before beginning its transaction. Prices and inventory are read multiple times, internal exception messages can be returned directly to the client, and an order is immediately marked as paid even though the script contains no payment gateway or payment confirmation.
Those are legitimate problems, and finding them matters.
But they weren't the main reason I wrote the test.
The Trap Was Hidden in a Perfectly Reasonable Transaction
Imagine there is one product left.
Two customers click checkout at almost exactly the same moment.
Request A reads the stock and sees 1. Request B can also read the stock and see 1. Both requests conclude that enough inventory exists, calculate a new stock value and attempt to complete the purchase.
The code already uses a transaction, which is precisely what makes the example interesting.
A developer — or an AI — can look at beginTransaction(), commit() and rollBack() and conclude that concurrency has been handled.
But simply putting a read-check-write sequence inside a transaction does not automatically make the inventory decision safe under concurrent access.
The stock itself needs to be protected appropriately, for example with a locking read such as SELECT ... FOR UPDATE, an atomic conditional update, or another concurrency strategy appropriate to the database and isolation model.
This was the part of the test I cared about most.
Would the AI understand what could happen when two instances of this code execute simultaneously, or would it simply recognize the word “transaction” and move on?
Even better: would a model correctly identify the race condition and then accidentally reproduce it in its supposedly corrected code?
That happened.
Why I Didn't Want a Simple AI Leaderboard
After reading the responses, it became obvious that counting the number of problems each model found would be misleading.
A model can produce twenty observations about code style, input validation and best practices while missing the one bug that can cause two customers to buy the same final item.
Another can find fewer problems but understand the concurrency model correctly.
There is also a third possibility that I think deserves more attention in AI coding evaluations: the model can identify the correct problem in its explanation and then fail to solve it in the code it generates.
That is potentially more dangerous than an obvious failure.
Code that doesn't compile is easy to reject.
Code that looks cleaner, uses prepared statements, introduces transactions and comes with a convincing explanation can create much more confidence. If the underlying architectural problem remains, the developer may stop looking precisely because the AI appears to have solved it.
ChatGPT
ChatGPT produced one of the broadest analyses of the experiment. It identified the expected SQL injection and XSS vulnerabilities, but also discussed CSRF, HTTP method validation, the incomplete cart stock check, N+1 queries, floating-point money handling, error leakage and several business-logic issues.
More importantly for this test, it explicitly identified the stock race condition and pointed out that the existing transaction alone does not solve it. It suggested either locking the relevant product rows or using an atomic conditional update.
It also identified another production issue that went beyond the deliberately planted trap: idempotency. A real checkout has to consider retries, timeouts and duplicate payment notifications, otherwise the same operation can potentially be processed more than once.
Ci sono parecchie cose da correggere. Le più pericolose sono SQL injection nel coupon/search, XSS nella ricerca, race condition sullo stock, e il fatto che l'ordine venga dichiarato paid senza alcuna prova di pagamento.
Problemi trovati
- SQL injection.
$coupone$searchentrano direttamente nell'SQL. Gli ID vengono convertiti aint, quindi lì l'injection è mitigata accidentalmente, ma resta una pratica fragile. Tutto deve passare da prepared statement. PHP raccomanda esplicitamente di parametrizzare l'input anziché inserirlo nella query. - Stored/reflected XSS. La pagina search stampa senza escaping sia
$searchsianameedescriptionprovenienti dal database. - CSRF su
addecheckout. L'autenticazione è basata sulla sessione/cookie, quindi un sito esterno potrebbe indurre il browser dell'utente a inviare POST. - Nessun controllo del metodo HTTP.
?action=checkoutdovrebbe accettare solo POST. - Race condition sullo stock. Due checkout possono leggere entrambi
stock = 1, entrambi decidere che va bene e poi entrambi scrivere0. La transazione da sola non risolve questo problema: bisogna acquisire un lock conSELECT ... FOR UPDATEoppure effettuare un UPDATE condizionale atomico. MySQL documenta esattamente questo comportamento dei locking reads. - Race condition anche su Add to cart. Due richieste contemporanee possono entrambe non trovare
$existinge creare due righe. Serve anche un vincoloUNIQUE(user_id, product_id). - Il controllo stock in
addè incompleto. Se nel carrello ho già 8 pezzi, stock 10, e ne aggiungo 5, il codice controlla solo10 < 5, quindi permette quantità totale 13. - Il checkout legge il carrello prima della transazione.
$itemse$totalpossono quindi non rappresentare più lo stesso stato del database quando parte la transazione. - Il totale viene calcolato prima dei lock. Prezzo, carrello o prodotti potrebbero cambiare tra calcolo e checkout.
- N+1 queries.
getCartTotal()esegue una query prodotti per ogni item. - Uso di
floatper denaro. I floating point possono introdurre errori di arrotondamento. Per soldi è preferibile DECIMAL nel DB e centesimi/interi nella logica applicativa. - Coupon non validato. Una percentuale >100, negativa, oppure uno sconto fisso negativo produrrebbe comportamenti assurdi. Mancano inoltre eventuali expiry, limiti di utilizzo e associazione utente, se previsti dal modello.
- Race sui coupon. Se un coupon ha
uses_remaining = 1, un sempliceSELECTseguito da un futuro decremento non basta: anche quella riga va bloccata/aggiornata atomicamente. - Prezzo degli
order_itemsviene riletto. L'ordine dovrebbe conservare esattamente il prezzo utilizzato per calcolare il totale, non fare una seconda SELECT. - Prodotti eliminati vengono silenziosamente ignorati da
getCartTotal(). Il checkout dovrebbe invece fallire esplicitamente. - Leak di errori interni.
jsonResponse(['error' => $e->getMessage()])può esporre dettagli SQL, struttura DB e altre informazioni utili a un attaccante. rollBack()senza verificareinTransaction().- Credenziali DB hardcoded. In produzione devono stare nella configurazione/secrets/environment.
- Action sconosciuta = cart.
?action=nonsensedovrebbe produrre 400/404, non comportarsi come una richiesta valida. - Trap architetturale:
INSERT ... status='paid'senza gateway, transaction ID o verifica del pagamento. Un checkout non equivale a un pagamento. Lo stato corretto è almenopendingfinché il provider non conferma il pagamento. - Idempotenza. Per un checkout con pagamento reale serve inoltre una chiave idempotente/identificativo univoco del tentativo di pagamento, altrimenti retry, timeout e webhook duplicati possono creare ordini doppi.
Le transazioni PDO garantiscono commit/rollback, ma la concorrenza richiede che sia il database a coordinare l'accesso alle righe; SELECT ... FOR UPDATE mantiene quei lock fino al commit/rollback.
Versione corretta
Ho mantenuto la struttura originale: stesso endpoint e stesse action cart, add, checkout, search.
<?php
declare(strict_types=1);
session_start();
$pdo = new PDO(
'mysql:host=localhost;dbname=shop;charset=utf8mb4',
getenv('SHOP_DB_USER') ?: 'shop_user',
getenv('SHOP_DB_PASSWORD') ?: '',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
function jsonResponse(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(
$data,
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
exit;
}
function requirePost(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
jsonResponse(['error' => 'Method not allowed'], 405);
}
}
function requireCsrf(): void
{
$expected = $_SESSION['csrf_token'] ?? '';
$received = $_POST['_csrf'] ?? '';
if (
!is_string($expected) ||
!is_string($received) ||
$expected === '' ||
!hash_equals($expected, $received)
) {
jsonResponse(['error' => 'Invalid CSRF token'], 403);
}
}
/*
* Convert a DECIMAL database value to integer cents.
* Assumes prices have at most 2 decimal places.
*/
function moneyToCents(string|int|float $value): int
{
$value = trim((string) $value);
if (!preg_match('/^-?\d+(?:\.\d{1,2})?$/', $value)) {
throw new RuntimeException('Invalid monetary value');
}
$negative = str_starts_with($value, '-');
if ($negative) {
$value = substr($value, 1);
}
[$whole, $decimal] = array_pad(explode('.', $value, 2), 2, '');
$decimal = str_pad($decimal, 2, '0');
$cents = ((int) $whole * 100) + (int) $decimal;
return $negative ? -$cents : $cents;
}
function centsToMoney(int $cents): float
{
return $cents / 100;
}
function getCartTotal(PDO $pdo, int $userId): int
{
$stmt = $pdo->prepare("
SELECT p.price, ci.quantity
FROM cart_items ci
INNER JOIN products p ON p.id = ci.product_id
WHERE ci.user_id = :user_id
");
$stmt->execute([
'user_id' => $userId,
]);
$total = 0;
foreach ($stmt->fetchAll() as $item) {
$quantity = (int) $item['quantity'];
if ($quantity < 1) {
throw new RuntimeException('Invalid cart quantity');
}
$total += moneyToCents($item['price']) * $quantity;
}
return $total;
}
/*
|--------------------------------------------------------------------------
| Authentication
|--------------------------------------------------------------------------
*/
if (!isset($_SESSION['user_id'])) {
jsonResponse(['error' => 'Authentication required'], 401);
}
$userId = filter_var(
$_SESSION['user_id'],
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
if ($userId === false) {
jsonResponse(['error' => 'Invalid session'], 401);
}
$action = $_GET['action'] ?? 'cart';
/*
|--------------------------------------------------------------------------
| ADD TO CART
|--------------------------------------------------------------------------
*/
if ($action === 'add') {
requirePost();
requireCsrf();
$productId = filter_input(
INPUT_POST,
'product_id',
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
$quantity = filter_input(
INPUT_POST,
'quantity',
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
if ($productId === false || $productId === null) {
jsonResponse(['error' => 'Invalid product'], 422);
}
if ($quantity === false || $quantity === null) {
jsonResponse(['error' => 'Invalid quantity'], 422);
}
try {
$pdo->beginTransaction();
/*
* Lock product row.
*
* This serializes concurrent modifications involving this product
* while we check the amount already present in the cart.
*/
$stmt = $pdo->prepare("
SELECT id, name, price, stock
FROM products
WHERE id = :product_id
FOR UPDATE
");
$stmt->execute([
'product_id' => $productId,
]);
$product = $stmt->fetch();
if (!$product) {
$pdo->rollBack();
jsonResponse(['error' => 'Product not found'], 404);
}
$stmt = $pdo->prepare("
SELECT id, quantity
FROM cart_items
WHERE user_id = :user_id
AND product_id = :product_id
FOR UPDATE
");
$stmt->execute([
'user_id' => $userId,
'product_id' => $productId,
]);
$existing = $stmt->fetch();
$currentQuantity = $existing
? (int) $existing['quantity']
: 0;
$newQuantity = $currentQuantity + $quantity;
if ($newQuantity > (int) $product['stock']) {
$pdo->rollBack();
jsonResponse([
'error' => 'Not enough stock'
], 409);
}
if ($existing) {
$stmt = $pdo->prepare("
UPDATE cart_items
SET quantity = :quantity
WHERE id = :id
");
$stmt->execute([
'quantity' => $newQuantity,
'id' => (int) $existing['id'],
]);
} else {
$stmt = $pdo->prepare("
INSERT INTO cart_items
(user_id, product_id, quantity)
VALUES
(:user_id, :product_id, :quantity)
");
$stmt->execute([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
]);
}
$pdo->commit();
jsonResponse([
'success' => true,
'message' => $product['name'] . ' added to cart',
]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log((string) $e);
jsonResponse([
'error' => 'Unable to add product to cart',
], 500);
}
}
/*
|--------------------------------------------------------------------------
| CHECKOUT
|--------------------------------------------------------------------------
*/
if ($action === 'checkout') {
requirePost();
requireCsrf();
$coupon = trim((string) ($_POST['coupon'] ?? ''));
try {
$pdo->beginTransaction();
/*
* Lock the cart itself first.
*
* Important: the cart is NOT read before the transaction anymore.
*/
$stmt = $pdo->prepare("
SELECT product_id, quantity
FROM cart_items
WHERE user_id = :user_id
ORDER BY product_id
FOR UPDATE
");
$stmt->execute([
'user_id' => $userId,
]);
$items = $stmt->fetchAll();
if (!$items) {
$pdo->rollBack();
jsonResponse([
'error' => 'Cart is empty',
], 422);
}
/*
* Lock products in a deterministic order.
*
* Using product_id order also reduces deadlock risk when
* transactions contain several products.
*/
$products = [];
$totalCents = 0;
$productStmt = $pdo->prepare("
SELECT id, name, price, stock
FROM products
WHERE id = :product_id
FOR UPDATE
");
foreach ($items as $item) {
$productId = (int) $item['product_id'];
$quantity = (int) $item['quantity'];
if ($quantity < 1) {
throw new RuntimeException('Invalid cart quantity');
}
$productStmt->execute([
'product_id' => $productId,
]);
$product = $productStmt->fetch();
if (!$product) {
throw new RuntimeException('Product unavailable');
}
if ((int) $product['stock'] < $quantity) {
throw new RuntimeException('Product unavailable');
}
$priceCents = moneyToCents($product['price']);
$products[$productId] = [
'id' => $productId,
'price_cents' => $priceCents,
'quantity' => $quantity,
];
$totalCents += $priceCents * $quantity;
}
/*
* Coupon.
*
* Prepared statement prevents SQL injection.
*/
if ($coupon !== '') {
if (strlen($coupon) > 100) {
throw new RuntimeException('Invalid coupon');
}
$stmt = $pdo->prepare("
SELECT id, type, value
FROM coupons
WHERE code = :code
AND active = 1
LIMIT 1
FOR UPDATE
");
$stmt->execute([
'code' => $coupon,
]);
$couponRow = $stmt->fetch();
if ($couponRow) {
$type = (string) $couponRow['type'];
if ($type === 'percentage') {
$percentage = (float) $couponRow['value'];
if ($percentage < 0 || $percentage > 100) {
throw new RuntimeException(
'Invalid coupon configuration'
);
}
/*
* Percentage converted back to integer cents.
*/
$discount = (int) round(
$totalCents * ($percentage / 100),
0,
PHP_ROUND_HALF_UP
);
$totalCents -= $discount;
} elseif ($type === 'fixed') {
$discountCents = moneyToCents(
$couponRow['value']
);
if ($discountCents < 0) {
throw new RuntimeException(
'Invalid coupon configuration'
);
}
$totalCents -= $discountCents;
} else {
throw new RuntimeException(
'Invalid coupon type'
);
}
}
}
$totalCents = max(0, $totalCents);
/*
* Stock is updated while the product rows are still locked.
*/
$updateStock = $pdo->prepare("
UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id
AND stock >= :quantity
");
foreach ($products as $product) {
$updateStock->execute([
'quantity' => $product['quantity'],
'product_id' => $product['id'],
]);
/*
* Defensive second check.
* Even if locking logic changes later, this UPDATE cannot
* make stock negative.
*/
if ($updateStock->rowCount() !== 1) {
throw new RuntimeException('Product unavailable');
}
}
/*
* IMPORTANT:
*
* Without a verified payment provider confirmation this should
* NOT be marked as "paid".
*/
$stmt = $pdo->prepare("
INSERT INTO orders
(user_id, total, status)
VALUES
(:user_id, :total, 'pending')
");
$stmt->execute([
'user_id' => $userId,
'total' => number_format(
$totalCents / 100,
2,
'.',
''
),
]);
$orderId = (int) $pdo->lastInsertId();
/*
* Persist exactly the prices used to calculate the order.
*/
$orderItemStmt = $pdo->prepare("
INSERT INTO order_items
(order_id, product_id, quantity, price)
VALUES
(:order_id, :product_id, :quantity, :price)
");
foreach ($products as $product) {
$orderItemStmt->execute([
'order_id' => $orderId,
'product_id' => $product['id'],
'quantity' => $product['quantity'],
'price' => number_format(
$product['price_cents'] / 100,
2,
'.',
''
),
]);
}
$stmt = $pdo->prepare("
DELETE FROM cart_items
WHERE user_id = :user_id
");
$stmt->execute([
'user_id' => $userId,
]);
$pdo->commit();
jsonResponse([
'success' => true,
'order_id' => $orderId,
'total' => centsToMoney($totalCents),
'status' => 'pending',
]);
} catch (RuntimeException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
/*
* Known business failure.
*/
jsonResponse([
'error' => $e->getMessage(),
], 409);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
/*
* Log details server-side, never expose them to the client.
*/
error_log((string) $e);
jsonResponse([
'error' => 'Checkout failed',
], 500);
}
}
/*
|--------------------------------------------------------------------------
| SEARCH
|--------------------------------------------------------------------------
*/
if ($action === 'search') {
$search = trim((string) ($_GET['q'] ?? ''));
if (mb_strlen($search) > 200) {
http_response_code(422);
exit('Search term too long');
}
$stmt = $pdo->prepare("
SELECT id, name, description, price
FROM products
WHERE name LIKE :search
OR description LIKE :search
LIMIT 20
");
$stmt->execute([
'search' => '%' . $search . '%',
]);
$products = $stmt->fetchAll();
header('Content-Type: text/html; charset=utf-8');
$escape = static fn (mixed $value): string =>
htmlspecialchars(
(string) $value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
echo '<h1>Search results for: '
. $escape($search)
. '</h1>';
foreach ($products as $product) {
echo '<h2>'
. $escape($product['name'])
. '</h2>';
echo '<p>'
. $escape($product['description'])
. '</p>';
echo '<strong>€'
. $escape($product['price'])
. '</strong>';
}
exit;
}
/*
|--------------------------------------------------------------------------
| CART TOTAL
|--------------------------------------------------------------------------
*/
if ($action === 'cart') {
try {
jsonResponse([
'total' => centsToMoney(
getCartTotal($pdo, $userId)
),
]);
} catch (Throwable $e) {
error_log((string) $e);
jsonResponse([
'error' => 'Unable to calculate cart',
], 500);
}
}
jsonResponse([
'error' => 'Unknown action',
], 404);
Gemini
Here is a comprehensive code review of the provided PHP script, organized by issues, architectural flaws, concurrency traps, and the refactored production code.
Critical Issues & Security Vulnerabilities
-
SQL Injection Vulnerabilities:
-
Search & Coupon:
$searchand$couponare direct string interpolations inside SQL queries. Attackers can inject arbitrary SQL. -
Numeric Parameters: Variables like
$userId,$productId, and$quantityare cast tointbefore insertion, which prevents SQL injection for those specific fields. However, using string interpolation for SQL queries remains bad practice—all queries must use parameterized prepared statements ($stmt->prepare()).
-
-
Cross-Site Scripting (XSS):
-
The
searchaction echoes raw user input ($search) and unescaped database outputs ($product['name'],$product['description']) directly into HTML without usinghtmlspecialchars().
-
-
Inconsistent Response Types:
-
Most endpoints return JSON, but
searchoutputs raw HTML. API endpoints should maintain a uniform output format (JSON).
-
-
Information Disclosure in Exception Handling:
-
Outputting
$e->getMessage()in thecatchblock risks leaking database schema details, SQL syntax errors, or connection string details to end-users.
-
Architectural Traps & Bugs
-
N+1 Query Problem:
-
getCartTotal()queriescart_items, loops over every row, and performs a separate query onproductsfor each item. This should be a singleJOINquery.
-
-
Double Read in Checkout:
-
During checkout, the code calls
getCartTotal(), then re-queriesproductsinside the transaction loop, and queriesproductsa third time to recordorder_items. All calculation and stock checking should happen inside the transaction using a single database query.
-
-
Float Precision for Financial Data:
-
Prices and totals are handled using native PHP
floatarithmetic ($total -= ...). Floating-point representation causes rounding errors in monetary calculations (e.g.,$0.1 + $0.2 !== $0.3). Database columns should useDECIMAL, and calculations should be formatted or handled in integer cents.
-
-
Unconstrained Stock Check in "Add to Cart":
-
Adding an item to the cart checks
$product['stock'] < $quantity, but ignores the quantity already in the cart ($existing['quantity']). A user could repeatedly add items within the single-request limit to exceed total stock.
-
Concurrency Traps (Race Conditions)
The "Obvious" Fix That Still Fails
An obvious fix for the checkout stock check is placing SELECT stock FROM products inside try { $pdo->beginTransaction(); ... }.
Why it fails under concurrent requests: A standard SELECT query inside a transaction uses non-locking reads (in InnoDB's default REPEATABLE READ isolation level). If two users initiate checkout for the last item in stock simultaneously:
-
User A starts a transaction and reads
stock = 1. -
User B starts a transaction and reads
stock = 1. -
Both pass the
$product['stock'] < $quantitycheck. -
User A updates
stock = 0and commits. -
User B updates
stock = -1and commits.
The Solution:
-
Option A (Pessimistic Locking): Use
SELECT ... FOR UPDATEinside the transaction to lock the product row until the transaction commits. -
Option B (Atomic UPDATE / Optimistic Constraint): Perform stock deductions directly in the
UPDATEquery with a check constraint:UPDATE products SET stock = stock - :qty WHERE id = :id AND stock >= :qty;IfrowCount()returns0, roll back the transaction—the stock was depleted by a concurrent request.
Refactored Code
Below is the corrected production-grade script with prepared statements, atomic transactions, row locking (FOR UPDATE), floating-point prevention, and consistent JSON output.
<?php
declare(strict_types=1);
session_start();
$pdo = new PDO(
'mysql:host=localhost;dbname=shop;charset=utf8mb4',
'shop_user',
'shop_password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
function jsonResponse(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function getCartTotal(PDO $pdo, int $userId): float
{
$stmt = $pdo->prepare("
SELECT COALESCE(SUM(p.price * c.quantity), 0) as total
FROM cart_items c
JOIN products p ON c.product_id = p.id
WHERE c.user_id = :user_id
");
$stmt->execute(['user_id' => $userId]);
return (float) $stmt->fetchColumn();
}
if (!isset($_SESSION['user_id'])) {
jsonResponse(['error' => 'Authentication required'], 401);
}
$userId = (int) $_SESSION['user_id'];
$action = $_GET['action'] ?? 'cart';
if ($action === 'add') {
$productId = (int) ($_POST['product_id'] ?? 0);
$quantity = (int) ($_POST['quantity'] ?? 1);
if ($quantity < 1) {
jsonResponse(['error' => 'Invalid quantity'], 422);
}
$stmt = $pdo->prepare("SELECT id, name, price, stock FROM products WHERE id = :id");
$stmt->execute(['id' => $productId]);
$product = $stmt->fetch();
if (!$product) {
jsonResponse(['error' => 'Product not found'], 404);
}
$stmt = $pdo->prepare("SELECT id, quantity FROM cart_items WHERE user_id = :user_id AND product_id = :product_id");
$stmt->execute(['user_id' => $userId, 'product_id' => $productId]);
$existing = $stmt->fetch();
$currentCartQty = $existing ? (int) $existing['quantity'] : 0;
if ($product['stock'] < ($currentCartQty + $quantity)) {
jsonResponse(['error' => 'Not enough stock available'], 409);
}
if ($existing) {
$stmt = $pdo->prepare("UPDATE cart_items SET quantity = quantity + :qty WHERE id = :id");
$stmt->execute(['qty' => $quantity, 'id' => $existing['id']]);
} else {
$stmt = $pdo->prepare("INSERT INTO cart_items (user_id, product_id, quantity) VALUES (:user_id, :product_id, :qty)");
$stmt->execute(['user_id' => $userId, 'product_id' => $productId, 'qty' => $quantity]);
}
jsonResponse([
'success' => true,
'message' => $product['name'] . ' added to cart'
]);
}
if ($action === 'checkout') {
$couponCode = $_POST['coupon'] ?? null;
try {
$pdo->beginTransaction();
// Lock cart items and product details using FOR UPDATE to prevent concurrent modifications
$stmt = $pdo->prepare("
SELECT c.product_id, c.quantity, p.name, p.price, p.stock
FROM cart_items c
JOIN products p ON c.product_id = p.id
WHERE c.user_id = :user_id
FOR UPDATE
");
$stmt->execute(['user_id' => $userId]);
$items = $stmt->fetchAll();
if (!$items) {
$pdo->rollBack();
jsonResponse(['error' => 'Cart is empty'], 422);
}
$rawTotal = 0.0;
foreach ($items as $item) {
if ($item['stock'] < $item['quantity']) {
$pdo->rollBack();
jsonResponse(['error' => "Product {$item['name']} is out of stock"], 409);
}
$rawTotal += $item['price'] * $item['quantity'];
}
$total = $rawTotal;
if ($couponCode !== null) {
$stmt = $pdo->prepare("SELECT type, value FROM coupons WHERE code = :code AND active = 1");
$stmt->execute(['code' => $couponCode]);
$couponRow = $stmt->fetch();
if ($couponRow) {
if ($couponRow['type'] === 'percentage') {
$total -= $total * ($couponRow['value'] / 100);
} else {
$total -= $couponRow['value'];
}
}
}
$total = max(0.0, round($total, 2));
// Deduct stock safely
$updateStockStmt = $pdo->prepare("UPDATE products SET stock = stock - :qty WHERE id = :id AND stock >= :qty");
foreach ($items as $item) {
$updateStockStmt->execute(['qty' => $item['quantity'], 'id' => $item['product_id']]);
if ($updateStockStmt->rowCount() === 0) {
throw new RuntimeException("Stock conflict for product ID: {$item['product_id']}");
}
}
// Insert Order
$stmt = $pdo->prepare("INSERT INTO orders (user_id, total, status) VALUES (:user_id, :total, 'paid')");
$stmt->execute(['user_id' => $userId, 'total' => $total]);
$orderId = (int) $pdo->lastInsertId();
// Insert Order Items
$insertItemStmt = $pdo->prepare("
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (:order_id, :product_id, :quantity, :price)
");
foreach ($items as $item) {
$insertItemStmt->execute([
'order_id' => $orderId,
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $item['price']
]);
}
// Clear Cart
$stmt = $pdo->prepare("DELETE FROM cart_items WHERE user_id = :user_id");
$stmt->execute(['user_id' => $userId]);
$pdo->commit();
jsonResponse([
'success' => true,
'order_id' => $orderId,
'total' => $total
]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// Log detailed exception internally (error_log($e->getMessage()))
jsonResponse(['error' => 'Checkout failed due to a server error.'], 500);
}
}
if ($action === 'search') {
$search = trim($_GET['q'] ?? '');
$stmt = $pdo->prepare("
SELECT id, name, description, price
FROM products
WHERE name LIKE :search_name
OR description LIKE :search_desc
LIMIT 20
");
$searchTerm = '%' . $search . '%';
$stmt->execute([
'search_name' => $searchTerm,
'search_desc' => $searchTerm,
]);
$products = $stmt->fetchAll();
jsonResponse([
'query' => $search,
'results' => $products
]);
}
jsonResponse([
'total' => getCartTotal($pdo, $userId)
]);
Grok
Code review (production lens)
This script is a small session-authenticated cart/checkout/search endpoint. It “runs,” but it is not safe for production. Problems are grouped by severity.
Critical security issues
1. SQL injection (everywhere) Almost every query interpolates values into SQL strings:
- $userId, $productId, $quantity, $newQuantity, $newStock, $orderId
- Coupon: WHERE code = '$coupon'
- Search: LIKE '%$search%'
Even when values are cast to (int), string interpolation is the wrong pattern. Coupon and search are fully attacker-controlled and trivially injectable (' OR 1=1 --, etc.). Why it matters: full database compromise, data exfiltration, or privilege escalation.
2. Cross-site scripting (XSS) in search
echo '<h1>Search results for: ' . $search . '</h1>';
echo '<h2>' . $product['name'] . '</h2>';
// ...
$search and product fields are echoed raw. Why it matters: stored/reflected XSS in an authenticated context → session theft, actions as the victim.
3. Hardcoded DB credentials User/password live in source. Why it matters: any source leak, backup, or repo access = database access.
4. No CSRF protection add and checkout are state-changing POSTs with only session auth. Why it matters: a malicious page can force a logged-in user to add items or place orders.
5. Weak session handling Bare session_start() with no HttpOnly / Secure / SameSite / strict mode / regeneration. Why it matters: easier session fixation and cookie theft (especially with the XSS above).
6. Exception messages returned to the client
'error' => $e->getMessage()
Why it matters: information disclosure (schema, constraint names, internal paths).
Logic / business bugs
7. Add-to-cart stock check is incomplete Only the incoming $quantity is checked against current stock. Existing cart quantity is ignored, so a user can push total cart quantity past stock.
8. Stock is never reserved on add Check happens at add time; real deduction happens only at checkout. Concurrent checkouts (or slow carts) can oversell.
9. Money as float $total is float; percentage coupons use float arithmetic. Why it matters: classic floating-point rounding errors on currency.
10. Coupon handling is unsafe and incomplete
- Applied with string-interpolated SQL.
- No max uses, per-user limits, expiry, or minimum order check.
- Type compared with loose ==.
- Can drive total negative (then clamped to 0) — free orders.
- Applied outside the transaction; coupon state can change before commit.
11. Prices can drift Order line prices are re-read after stock updates. If an admin changes price mid-checkout, the order total and line prices can disagree with what the customer saw.
12. N+1 queries and silent data loss in getCartTotal One query per cart line; missing products are skipped, so the total can under-report without warning.
13. Inconsistent response shape Most actions return JSON; search returns HTML and exits. Clients (and security scanners) get a confusing hybrid API.
14. Order marked 'paid' with no payment step Architectural assumption that “checkout” = payment completed. Dangerous if this endpoint is ever exposed as a real payment flow.
Architectural trap
15. Business logic, auth, HTML rendering, and raw SQL in one procedural script No separation of query layer, domain rules, or presentation. The same file both mutates inventory and renders HTML search results. That makes correct locking, testing, and future changes hard, and it encourages more ad-hoc SQL (the pattern that already produced the injection holes).
Concurrency (the part that still bites after “obvious” fixes)
16. Classic TOCTOU / lost-update on stock Checkout does:
- SELECT stock ...
- application check
- UPDATE stock = $newStock
Even inside a transaction, without row locks two concurrent checkouts can both read stock = 5, both decide 3 is fine, both write, and you oversell.
Obvious-looking fix that is still wrong under concurrency:
$pdo->beginTransaction();
// SELECT stock (no FOR UPDATE)
// if enough → UPDATE stock = stock - qty
$pdo->commit();
A transaction alone does not serialize the read–check–write under typical isolation. You need either:
- SELECT ... FOR UPDATE (pessimistic lock) inside the transaction, or
- an atomic conditional update, e.g. UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ? and check affected rows.
17. Concurrent checkout of the same cart Two parallel checkout requests for the same user can both read the same cart lines and both create orders (double charge / double stock decrement) unless the cart (or user) is locked or checkout is made idempotent.
18. Cart mutation during checkout Another request can add while checkout is running; without locking cart rows you can sell a different set of items than the total you computed.
Corrected version
Preserves the same actions and response shapes (JSON for cart/add/checkout, HTML for search), fixes injection, XSS, the incomplete stock check on add, and the stock race with proper locking. CSRF is noted but not invented as a new required parameter (that would change the client contract); production should add it.
<?php
declare(strict_types=1);
// Harden session *before* session_start()
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
'httponly' => true,
'samesite' => 'Lax',
]);
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
session_start();
// Prefer env vars in real deployments; never commit real secrets.
$pdo = new PDO(
getenv('DB_DSN') ?: 'mysql:host=localhost;dbname=shop;charset=utf8mb4',
getenv('DB_USER') ?: 'shop_user',
getenv('DB_PASS') ?: 'shop_password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
function jsonResponse(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_THROW_ON_ERROR);
exit;
}
/**
* Cart total using a single JOIN (avoids N+1).
* Prices are still float for schema compatibility — prefer DECIMAL/cents long-term.
*/
function getCartTotal(PDO $pdo, int $userId): float
{
$stmt = $pdo->prepare(
'SELECT COALESCE(SUM(p.price * c.quantity), 0) AS total
FROM cart_items c
INNER JOIN products p ON p.id = c.product_id
WHERE c.user_id = ?'
);
$stmt->execute([$userId]);
return (float) $stmt->fetchColumn();
}
if (!isset($_SESSION['user_id'])) {
jsonResponse(['error' => 'Authentication required'], 401);
}
$userId = (int) $_SESSION['user_id'];
$action = $_GET['action'] ?? 'cart';
if ($action === 'add') {
$productId = (int) ($_POST['product_id'] ?? 0);
$quantity = (int) ($_POST['quantity'] ?? 1);
if ($productId < 1 || $quantity < 1) {
jsonResponse(['error' => 'Invalid product or quantity'], 422);
}
// Lock product row so concurrent adds/checkouts see consistent stock
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'SELECT id, name, price, stock FROM products WHERE id = ? FOR UPDATE'
);
$stmt->execute([$productId]);
$product = $stmt->fetch();
if (!$product) {
$pdo->rollBack();
jsonResponse(['error' => 'Product not found'], 404);
}
$stmt = $pdo->prepare(
'SELECT id, quantity FROM cart_items
WHERE user_id = ? AND product_id = ? FOR UPDATE'
);
$stmt->execute([$userId, $productId]);
$existing = $stmt->fetch();
$currentQty = $existing ? (int) $existing['quantity'] : 0;
$newQuantity = $currentQty + $quantity;
if ((int) $product['stock'] < $newQuantity) {
$pdo->rollBack();
jsonResponse(['error' => 'Not enough stock'], 409);
}
if ($existing) {
$upd = $pdo->prepare('UPDATE cart_items SET quantity = ? WHERE id = ?');
$upd->execute([$newQuantity, $existing['id']]);
} else {
$ins = $pdo->prepare(
'INSERT INTO cart_items (user_id, product_id, quantity) VALUES (?, ?, ?)'
);
$ins->execute([$userId, $productId, $quantity]);
}
$pdo->commit();
jsonResponse([
'success' => true,
'message' => $product['name'] . ' added to cart',
]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// Do not leak internal details
jsonResponse(['error' => 'Unable to update cart'], 500);
}
}
if ($action === 'checkout') {
$couponCode = isset($_POST['coupon']) ? trim((string) $_POST['coupon']) : null;
$pdo->beginTransaction();
try {
// Lock this user's cart rows so concurrent checkout/add cannot interleave
$stmt = $pdo->prepare(
'SELECT id, product_id, quantity
FROM cart_items
WHERE user_id = ?
FOR UPDATE'
);
$stmt->execute([$userId]);
$items = $stmt->fetchAll();
if (!$items) {
$pdo->rollBack();
jsonResponse(['error' => 'Cart is empty'], 422);
}
$lineSnapshots = []; // product_id => [price, quantity, name]
$total = 0.0;
// Lock each product in stable id order to reduce deadlock risk
$productIds = array_unique(array_map(static fn($i) => (int) $i['product_id'], $items));
sort($productIds);
$productById = [];
$lockStmt = $pdo->prepare(
'SELECT id, name, price, stock FROM products WHERE id = ? FOR UPDATE'
);
foreach ($productIds as $pid) {
$lockStmt->execute([$pid]);
$row = $lockStmt->fetch();
if (!$row) {
throw new RuntimeException('Product unavailable');
}
$productById[$pid] = $row;
}
foreach ($items as $item) {
$pid = (int) $item['product_id'];
$qty = (int) $item['quantity'];
$product = $productById[$pid];
if ((int) $product['stock'] < $qty) {
throw new RuntimeException('Product unavailable');
}
$price = (float) $product['price'];
$lineSnapshots[] = [
'product_id' => $pid,
'quantity' => $qty,
'price' => $price,
];
$total += $price * $qty;
$upd = $pdo->prepare(
'UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?'
);
$upd->execute([$qty, $pid, $qty]);
if ($upd->rowCount() !== 1) {
// Belt-and-suspenders against races even with FOR UPDATE
throw new RuntimeException('Product unavailable');
}
}
// Coupon: re-validate inside the same transaction
if ($couponCode !== null && $couponCode !== '') {
$cStmt = $pdo->prepare(
'SELECT id, type, value FROM coupons
WHERE code = ? AND active = 1
FOR UPDATE'
);
$cStmt->execute([$couponCode]);
$couponRow = $cStmt->fetch();
if ($couponRow) {
if ($couponRow['type'] === 'percentage') {
$total -= $total * ((float) $couponRow['value'] / 100.0);
} else {
$total -= (float) $couponRow['value'];
}
// Optional: mark coupon used / decrement remaining uses here
}
}
if ($total < 0) {
$total = 0.0;
}
// Snapshot total at order time; status 'paid' kept for behavioural compatibility
$orderStmt = $pdo->prepare(
'INSERT INTO orders (user_id, total, status) VALUES (?, ?, ?)'
);
$orderStmt->execute([$userId, $total, 'paid']);
$orderId = (int) $pdo->lastInsertId();
$itemStmt = $pdo->prepare(
'INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (?, ?, ?, ?)'
);
foreach ($lineSnapshots as $line) {
$itemStmt->execute([
$orderId,
$line['product_id'],
$line['quantity'],
$line['price'],
]);
}
$del = $pdo->prepare('DELETE FROM cart_items WHERE user_id = ?');
$del->execute([$userId]);
$pdo->commit();
jsonResponse([
'success' => true,
'order_id' => $orderId,
'total' => $total,
]);
} catch (RuntimeException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// Business rule failures (stock) — message is intentional and safe
jsonResponse(['error' => $e->getMessage()], 409);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
jsonResponse(['error' => 'Checkout failed'], 500);
}
}
if ($action === 'search') {
$search = (string) ($_GET['q'] ?? '');
// Escape for LIKE wildcards; still use a bound parameter
$like = '%' . addcslashes($search, '%_\\') . '%';
$stmt = $pdo->prepare(
'SELECT id, name, description, price
FROM products
WHERE name LIKE ? OR description LIKE ?
LIMIT 20'
);
$stmt->execute([$like, $like]);
$products = $stmt->fetchAll();
header('Content-Type: text/html; charset=utf-8');
echo '<h1>Search results for: ' . htmlspecialchars($search, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>';
foreach ($products as $product) {
echo '<h2>' . htmlspecialchars((string) $product['name'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h2>';
echo '<p>' . htmlspecialchars((string) $product['description'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>';
echo '<strong>€' . htmlspecialchars((string) $product['price'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</strong>';
}
exit;
}
// Default: cart total
jsonResponse([
'total' => getCartTotal($pdo, $userId),
]);
What this fixes vs. what remains a design risk
| Area | Status after fix |
|---|---|
| SQL injection | Fixed (prepared statements) |
| XSS in search | Fixed (htmlspecialchars) |
| Incomplete stock on add | Fixed (total cart qty vs stock, under lock) |
| Oversell race | Fixed (FOR UPDATE + conditional UPDATE) |
| Cart race during checkout | Mitigated (cart rows locked) |
| Leaked exceptions | Fixed (generic 500) |
| CSRF | Not fixed — needs tokens or SameSite-strict + careful cookie design; changing the request shape was avoided |
| Money as float | Still present (schema-compatible); migrate to integer cents / DECIMAL |
| Coupon limits / single-use | Structure ready (FOR UPDATE); business rules not fully specified |
| Hardcoded credentials | Mitigated via getenv with fallback — remove fallbacks in real deploy |
| Hybrid JSON/HTML API | Unchanged (behaviour preserved); split endpoints later |
The concurrency trap called out explicitly: wrapping stock logic in beginTransaction() / commit() without SELECT ... FOR UPDATE (or an atomic conditional update) looks correct and still allows overselling under concurrent requests. The corrected checkout does both locking and a conditional stock update so a second concurrent transaction cannot succeed on the same units.
Perplexity
The code has SQL injection, stored/reflected XSS, CSRF, unsafe error disclosure, broken inventory and pricing semantics, race conditions, and several transaction-handling bugs. The most important architectural trap is that a preliminary “stock check” is not a reservation: concurrent add-to-cart and checkout requests can both pass it, so only an atomic conditional stock decrement inside the checkout transaction is reliable. OWASP recommends CSRF protection on all state-changing requests and secure, HttpOnly, SameSite session cookies; monetary values should use exact decimal or minor-unit arithmetic rather than binary floating point.cheatsheetseries.owasp+2
Problems found
Database and input handling
-
SQL injection exists in several places. User-controlled or session-derived values are interpolated into SQL:
-
coupon. -
search. -
productId,quantity, anduserId. -
IDs read from database rows are safer than request input, but should still be bound consistently.
-
-
Prepared statements are not enough unless values are validated too. Bind integer IDs and quantities as integers, validate maximum quantities, and validate coupon length and format.
-
The search query is vulnerable to SQL injection through both the query string and the
LIKEpattern. -
The
LIKEwildcard behavior is uncontrolled.%and_supplied by a user act as wildcards. That may be acceptable for search, but it should be deliberate and escaped if literal matching is intended. -
The query uses
SELECT *unnecessarily. Explicit columns reduce coupling and accidental exposure. -
The N+1 query pattern is severe.
getCartTotal()queries products once per cart item, and checkout queries products repeatedly. This increases latency and creates inconsistent snapshots. -
The cart lookup assumes a uniqueness constraint. Without
UNIQUE(user_id, product_id), concurrent inserts can create duplicate rows and make totals, updates, and deletion ambiguous. -
No database schema assumptions are enforced. The application needs:
-
Foreign keys from cart and order items.
-
Positive quantity constraints.
-
Nonnegative stock constraints.
-
A unique cart key.
-
Suitable indexes.
-
-
Hard-coded database credentials are a deployment and secret-management risk. Use environment variables or a secret manager, and use a restricted database account.
-
No explicit connection options for native prepares. With MySQL, set
PDO::ATTR_EMULATE_PREPARES => false.
Authentication and request security
-
Session cookie settings are not configured in this file. The session should use HTTPS-only,
HttpOnly, and an appropriateSameSitepolicy. OWASP specifically recommends secure cookie handling and HTTPS for the whole session.cheatsheetseries.owasp+1 -
CSRF protection is missing. An attacker could cause a logged-in browser to add products or submit checkout from another site. All state-changing requests need a server-validated CSRF token.cheatsheetseries.owasp
-
Authentication is only a presence check.
$_SESSION['user_id']should be validated as a positive integer, and the session should be established only after successful authentication. -
Session regeneration belongs in the login flow. After authentication, call
session_regenerate_id(true)to prevent session fixation; this endpoint cannot repair a login flow that failed to do it.owasp -
HTTP method restrictions are missing.
addandcheckoutshould accept POST only;searchand cart reads should accept GET. -
No rate limiting or request-size limits are shown. Search and checkout endpoints should have operational limits, especially if publicly exposed.
-
No content-security policy or other response hardening is shown. This is not a replacement for output escaping, but headers such as CSP and
X-Content-Type-Options: nosniffprovide defense in depth.
Output and error handling
-
Search output has reflected XSS.
$searchis inserted into HTML without escaping. -
Product name and description have stored XSS. Database content is rendered directly into HTML. Escape HTML output with
htmlspecialchars(..., ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). -
The endpoint has inconsistent response formats. Most actions return JSON, but search returns HTML. Preserve that intended behavior if necessary, but make the boundary explicit and escape it correctly.
-
json_encode()is unchecked. UseJSON_THROW_ON_ERROR; otherwise encoding failures can produce invalid or incomplete responses. -
Exceptions disclose internal details. Returning
$e->getMessage()can reveal SQL, schema, or infrastructure information. Log the exception server-side and return a generic error to the client. -
The catch block can itself fail. Calling
rollBack()when no transaction is active can raise another exception. CheckinTransaction(). -
JSON responses should specify UTF-8. Use
application/json; charset=utf-8. -
Responses should not be cached when they contain user/cart data. Send
Cache-Control: no-store.
Cart and pricing logic
-
The add-to-cart stock check is semantically wrong. It checks only the requested increment against stock, not the existing cart quantity plus the increment. A cart with 8 units can add 5 when stock is 10.
-
That check is also race-prone. Two concurrent requests can both pass the check and create a cart quantity exceeding stock. The robust fix is an atomic update or a transaction with locking and a uniqueness constraint.
-
Quantity has no upper bound. Large values can cause abuse, integer overflow in other layers, or impractical orders.
-
Prices are handled as
float. Currency arithmetic can produce rounding errors. Use databaseDECIMALand string-based decimal arithmetic, BCMath, or integer minor units.isapp -
The cart total is not a durable price snapshot. Product prices can change between cart display and checkout. Checkout must define its policy: use the current price, or snapshot the price into the cart/order. The corrected version below uses the current price at checkout and records it in
order_items. -
The coupon is applied outside the checkout transaction. The coupon can change or be deactivated between validation and order creation.
-
Coupon rules are incomplete. The code does not check expiry, usage limits, minimum spend, per-user use, currency, or whether the discount value is valid.
-
Percentage discounts are not bounded. A value above 100 can make the total negative before clamping. The total should be calculated using explicit coupon rules and decimal arithmetic.
-
The coupon’s accepted-but-invalid behavior is unclear. Silently ignoring an invalid coupon can surprise customers. The corrected version returns a validation error for a supplied invalid coupon; change this if the intended UI requires silent ignoring.
-
No idempotency protection exists for checkout. A client retry after a timeout can create two orders. A production payment/order endpoint needs an idempotency key or a unique client/order token.
-
The code marks the order
paidwithout a payment provider or payment verification. This is a major business-logic/security issue. The example preserves the original behavior, but real code must setpendinguntil a trusted payment confirmation is received. -
The code does not create a payment transaction or audit record. That makes reconciliation and safe retries difficult.
-
Stock is updated by reading, calculating, then writing. This is the central overselling race.
-
Order items re-read product prices after stock updates. A price change or deleted product can produce inconsistent data; the price should be selected and retained in the same locked row.
-
A product could disappear between queries. Foreign keys and one consistent transaction snapshot are needed.
Transaction and concurrency issues
-
Checkout does not lock rows before relying on their values. Under default MySQL isolation, two checkouts can both read the same stock.
-
The apparent fix “put the existing stock check inside a transaction” is still insufficient. A plain
SELECTinside a transaction does not necessarily lock the row. UseSELECT ... FOR UPDATE, or better, an atomic conditional update. -
The strongest stock fix is an atomic conditional decrement:
sqlUPDATE products SET stock = stock - :quantity WHERE id = :id AND stock >= :quantityCheck
rowCount() === 1. This prevents two transactions from decrementing the same units. -
However, atomic stock decrement alone is not enough if later order creation can fail. It must be inside the same transaction as order and order-item creation, so rollback restores the stock.
-
Concurrent add requests require a unique cart key and an upsert strategy. A check-then-insert sequence is vulnerable even if the values are prepared.
-
Concurrent checkout requests can both load the same cart before either deletes it. Row locking on cart items, an idempotency key, or a cart “checkout state” is required. The corrected version locks cart rows, so a second checkout waits and then sees an empty cart after the first commits.
-
Deadlocks are possible when multiple products are locked in different orders. Sort cart items by
product_idbefore locking products, and retry deadlocked transactions at the application boundary. -
The transaction does not guarantee payment correctness. Database locking prevents inventory races, not duplicate payment captures or duplicate HTTP retries.
Corrected version
This version preserves the three actions—cart, add, checkout, and search—and keeps search as HTML. It assumes:
-
products.priceandcoupons.valueareDECIMAL. -
cart_items.quantityis an integer. -
coupons.codeis case-normalized or compared according to the intended collation. -
order_items.priceisDECIMAL. -
The database has a unique cart constraint, shown below.
<?php
declare(strict_types=1);
/*
* Production configuration should provide these through the environment
* or a secret manager, not source control.
*/
$dsn = getenv('SHOP_DSN') ?: 'mysql:host=localhost;dbname=shop;charset=utf8mb4';
$dbUser = getenv('SHOP_DB_USER') ?: 'shop_user';
$dbPassword = getenv('SHOP_DB_PASSWORD') ?: 'shop_password';
session_set_cookie_params([
'secure' => true, // Requires HTTPS in production.
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$pdo = new PDO(
$dsn,
$dbUser,
$dbPassword,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
function jsonResponse(array $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
try {
echo json_encode(
$data,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);
} catch (JsonException) {
http_response_code(500);
echo '{"error":"Response encoding failed"}';
}
exit;
}
function htmlEscape(string $value): string
{
return htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
}
function requireMethod(string ...$methods): void
{
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if (!in_array($method, $methods, true)) {
header('Allow: ' . implode(', ', $methods));
jsonResponse(['error' => 'Method not allowed'], 405);
}
}
function requireCsrfToken(): void
{
$provided = (string)($_POST['csrf_token'] ?? '');
$expected = (string)($_SESSION['csrf_token'] ?? '');
if (
$expected === '' ||
$provided === '' ||
!hash_equals($expected, $provided)
) {
jsonResponse(['error' => 'Invalid CSRF token'], 403);
}
}
function decimalToCents(string $value): int
{
/*
* Assumes EUR-style two-decimal prices. In a multi-currency system,
* use currency-specific minor units or a Money value object.
*/
if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $value)) {
throw new InvalidArgumentException('Invalid monetary value');
}
[$whole, $fraction] = array_pad(explode('.', $value, 2), 2, '');
$fraction = str_pad($fraction, 2, '0');
return (int)$whole * 100 + (int)$fraction;
}
function centsToDecimal(int $cents): string
{
return sprintf('%d.%02d', intdiv($cents, 100), $cents % 100);
}
function getCartItems(PDO $pdo, int $userId, bool $forUpdate = false): array
{
$sql = <<<'SQL'
SELECT
ci.product_id,
ci.quantity,
p.name,
p.price,
p.stock
FROM cart_items AS ci
INNER JOIN products AS p ON p.id = ci.product_id
WHERE ci.user_id = :user_id
ORDER BY ci.product_id
SQL;
if ($forUpdate) {
$sql .= ' FOR UPDATE';
}
$stmt = $pdo->prepare($sql);
$stmt->execute(['user_id' => $userId]);
return $stmt->fetchAll();
}
function getCartTotalCents(PDO $pdo, int $userId): int
{
$items = getCartItems($pdo, $userId);
$total = 0;
foreach ($items as $item) {
$priceCents = decimalToCents((string)$item['price']);
$quantity = (int)$item['quantity'];
$total += $priceCents * $quantity;
}
return $total;
}
function findCoupon(PDO $pdo, string $code, bool $forUpdate = false): ?array
{
$sql = <<<'SQL'
SELECT id, code, type, value, active
FROM coupons
WHERE code = :code
AND active = 1
LIMIT 1
SQL;
if ($forUpdate) {
$sql .= ' FOR UPDATE';
}
$stmt = $pdo->prepare($sql);
$stmt->execute(['code' => $code]);
$coupon = $stmt->fetch();
return $coupon ?: null;
}
if (!isset($_SESSION['user_id']) || !filter_var(
$_SESSION['user_id'],
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
)) {
jsonResponse(['error' => 'Authentication required'], 401);
}
$userId = (int)$_SESSION['user_id'];
$action = (string)($_GET['action'] ?? 'cart');
if ($action === 'add') {
requireMethod('POST');
requireCsrfToken();
$productId = filter_var(
$_POST['product_id'] ?? null,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
$quantity = filter_var(
$_POST['quantity'] ?? 1,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1, 'max_range' => 100]]
);
if ($productId === false || $quantity === false) {
jsonResponse(['error' => 'Invalid product or quantity'], 422);
}
try {
$pdo->beginTransaction();
/*
* Lock the product while checking stock and changing the cart.
* This serializes concurrent add operations for this product.
*/
$stmt = $pdo->prepare(<<<'SQL'
SELECT id, name, stock
FROM products
WHERE id = :product_id
FOR UPDATE
SQL);
$stmt->execute(['product_id' => $productId]);
$product = $stmt->fetch();
if (!$product) {
$pdo->rollBack();
jsonResponse(['error' => 'Product not found'], 404);
}
$stmt = $pdo->prepare(<<<'SQL'
SELECT id, quantity
FROM cart_items
WHERE user_id = :user_id
AND product_id = :product_id
FOR UPDATE
SQL);
$stmt->execute([
'user_id' => $userId,
'product_id' => $productId,
]);
$existing = $stmt->fetch();
$newQuantity = $quantity + ($existing ? (int)$existing['quantity'] : 0);
if ((int)$product['stock'] < $newQuantity) {
$pdo->rollBack();
jsonResponse(['error' => 'Not enough stock'], 409);
}
if ($existing) {
$stmt = $pdo->prepare(<<<'SQL'
UPDATE cart_items
SET quantity = :quantity
WHERE id = :id
SQL);
$stmt->execute([
'quantity' => $newQuantity,
'id' => (int)$existing['id'],
]);
} else {
$stmt = $pdo->prepare(<<<'SQL'
INSERT INTO cart_items (user_id, product_id, quantity)
VALUES (:user_id, :product_id, :quantity)
SQL);
$stmt->execute([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
]);
}
$pdo->commit();
jsonResponse([
'success' => true,
'message' => (string)$product['name'] . ' added to cart',
]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log((string)$e);
jsonResponse(['error' => 'Unable to update cart'], 500);
}
}
if ($action === 'checkout') {
requireMethod('POST');
requireCsrfToken();
$couponCode = trim((string)($_POST['coupon'] ?? ''));
if (strlen($couponCode) > 64) {
jsonResponse(['error' => 'Invalid coupon'], 422);
}
try {
$pdo->beginTransaction();
/*
* Lock cart rows first. A concurrent checkout for the same user
* waits here and then observes the committed cart deletion.
*/
$items = getCartItems($pdo, $userId, true);
if (!$items) {
$pdo->rollBack();
jsonResponse(['error' => 'Cart is empty'], 422);
}
$subtotalCents = 0;
foreach ($items as $item) {
$quantity = (int)$item['quantity'];
if ($quantity < 1 || $quantity > 100) {
throw new RuntimeException('Invalid cart quantity');
}
$subtotalCents +=
decimalToCents((string)$item['price']) * $quantity;
}
$totalCents = $subtotalCents;
if ($couponCode !== '') {
$coupon = findCoupon($pdo, $couponCode, true);
if (!$coupon) {
$pdo->rollBack();
jsonResponse(['error' => 'Invalid coupon'], 422);
}
$couponType = (string)$coupon['type'];
$couponValue = (string)$coupon['value'];
if ($couponType === 'percentage') {
$percentage = (float)$couponValue;
if ($percentage < 0 || $percentage > 100) {
throw new RuntimeException('Invalid coupon configuration');
}
/*
* Round once to the nearest cent. For stricter financial
* requirements, use BCMath or integer basis-point rules.
*/
$discountCents = (int)round(
$subtotalCents * ($percentage / 100)
);
} else {
$discountCents = decimalToCents($couponValue);
}
$totalCents = max(0, $subtotalCents - $discountCents);
}
/*
* Lock products in deterministic product_id order, then perform
* an atomic conditional decrement. The conditional update is the
* part that prevents overselling.
*/
$stockUpdate = $pdo->prepare(<<<'SQL'
UPDATE products
SET stock = stock - :quantity
WHERE id = :product_id
AND stock >= :quantity
SQL);
foreach ($items as $item) {
$stockUpdate->execute([
'quantity' => (int)$item['quantity'],
'product_id' => (int)$item['product_id'],
]);
if ($stockUpdate->rowCount() !== 1) {
throw new RuntimeException('Product unavailable');
}
}
$orderStmt = $pdo->prepare(<<<'SQL'
INSERT INTO orders (user_id, total, status)
VALUES (:user_id, :total, :status)
SQL);
$orderStmt->execute([
'user_id' => $userId,
'total' => centsToDecimal($totalCents),
/*
* Do not mark this paid until a trusted payment processor
* confirms payment. This preserves the sample's behavior,
* but production code should normally use "pending".
*/
'status' => 'paid',
]);
$orderId = (int)$pdo->lastInsertId();
$itemStmt = $pdo->prepare(<<<'SQL'
INSERT INTO order_items
(order_id, product_id, quantity, price)
VALUES
(:order_id, :product_id, :quantity, :price)
SQL);
foreach ($items as $item) {
$itemStmt->execute([
'order_id' => $orderId,
'product_id' => (int)$item['product_id'],
'quantity' => (int)$item['quantity'],
'price' => (string)$item['price'],
]);
}
$deleteStmt = $pdo->prepare(<<<'SQL'
DELETE FROM cart_items
WHERE user_id = :user_id
SQL);
$deleteStmt->execute(['user_id' => $userId]);
$pdo->commit();
jsonResponse([
'success' => true,
'order_id' => $orderId,
'total' => centsToDecimal($totalCents),
]);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log((string)$e);
jsonResponse(['error' => 'Checkout failed'], 500);
}
}
if ($action === 'search') {
requireMethod('GET');
$search = trim((string)($_GET['q'] ?? ''));
if (mb_strlen($search, 'UTF-8') > 100) {
jsonResponse(['error' => 'Search query too long'], 422);
}
/*
* The wildcard characters remain search operators here. If literal
* matching is wanted, escape %, _, and the escape character and add
* "ESCAPE '\\'" to the SQL expression.
*/
$pattern = '%' . $search . '%';
$stmt = $pdo->prepare(<<<'SQL'
SELECT id, name, description, price
FROM products
WHERE name LIKE :pattern
OR description LIKE :pattern
ORDER BY id DESC
LIMIT 20
SQL);
$stmt->execute(['pattern' => $pattern]);
$products = $stmt->fetchAll();
header('Content-Type: text/html; charset=utf-8');
echo '<h1>Search results for: '
. htmlEscape($search)
. '</h1>';
foreach ($products as $product) {
echo '<h2>' . htmlEscape((string)$product['name']) . '</h2>';
echo '<p>' . nl2br(
htmlEscape((string)$product['description']),
false
) . '</p>';
echo '<strong>€' . htmlEscape((string)$product['price']) . '</strong>';
}
exit;
}
if ($action === 'cart') {
requireMethod('GET');
jsonResponse([
'total' => centsToDecimal(getCartTotalCents($pdo, $userId)),
]);
}
jsonResponse(['error' => 'Unknown action'], 404);
Required database constraints
Application code should not be the only line of defense. At minimum:
ALTER TABLE cart_items
ADD CONSTRAINT uq_cart_user_product
UNIQUE (user_id, product_id);
ALTER TABLE cart_items
ADD CONSTRAINT chk_cart_quantity
CHECK (quantity > 0);
ALTER TABLE products
ADD CONSTRAINT chk_product_stock
CHECK (stock >= 0);
ALTER TABLE cart_items
ADD CONSTRAINT fk_cart_product
FOREIGN KEY (product_id) REFERENCES products(id);
ALTER TABLE order_items
ADD CONSTRAINT fk_order_item_product
FOREIGN KEY (product_id) REFERENCES products(id);
If duplicate cart rows already exist, clean them up before adding the unique constraint. Also verify that all involved tables use InnoDB; row-level locking and transactional rollback will not work as intended with a nontransactional engine.
Obvious fixes that remain unsafe
“Just use prepared statements”
Prepared statements eliminate SQL injection, but they do not solve:
-
Overselling inventory.
-
Duplicate cart rows.
-
Duplicate checkout retries.
-
Invalid coupon rules.
-
Incorrect payment status.
-
Floating-point currency errors.
They are necessary, not sufficient.
“Just wrap checkout in a transaction”
A transaction alone does not make a read-modify-write sequence safe. Two transactions can both read the same stock unless rows are locked or the update is conditional. Use FOR UPDATE or an atomic conditional decrement, and keep the entire stock/order/cart operation in the same transaction.
“Just check stock when adding to the cart”
That check becomes stale immediately when another customer purchases the item. A cart is not a reservation unless the application explicitly reserves stock with an expiry mechanism. Final availability must be enforced during checkout.
“Just delete the cart after creating the order”
Concurrent requests can both read the cart before either request deletes it. Lock the cart rows, use an idempotency key, or introduce an explicit checkout state. The corrected code locks the cart rows, but an idempotency key is still recommended for robust retry handling.
“Just mark the order paid”
This is unsafe unless payment has been verified by a trusted server-side payment flow. A browser request must never be the authority that establishes payment. In a real integration, create the order as pending, use a payment-provider idempotency key, and transition to paid only after verified confirmation.
Gemma 3 4B
Gemma identified several genuine security issues, particularly SQL injection, and noticed that concurrency deserved attention. But its response also showed why confident code reviews need to be checked carefully.
It claimed that the never return type used by jsonResponse() was invalid PHP. That is incorrect in modern PHP: never is a valid return type and is appropriate for a function that terminates execution rather than returning normally.
Its corrected version also introduced problems of its own and did not preserve the complete working application as requested.
That makes the result interesting for a different reason. The model wasn't merely missing bugs. It was also creating findings that looked technically plausible but were wrong.
Okay, this is a complex PHP script with several significant issues. Let's break down the problems, explain why they matter, and then present a corrected version.
1. Critical Security Vulnerabilities:
- SQL Injection: The code repeatedly uses string interpolation directly into SQL queries. This is extremely dangerous because it allows an attacker to inject malicious SQL code through
$_GETor$_POSTparameters. For example, if someone sets?action=add&product_id=1; DROP TABLE products; --in the URL, they could potentially delete your entire database. This is the single most important issue. - Lack of Input Validation: While some input validation exists (checking for numeric types and sufficient quantity), it’s inconsistent and easily bypassed. It doesn't sanitize user-provided data, leaving the application open to XSS or other vulnerabilities.
- Session Management Issues: The code relies solely on
session_start()without proper session initialization or security measures. This can lead to issues like predictable session IDs and potential session hijacking. The lack of checking if$_SESSION['user_id']is set before using it is a serious problem.
2. Architectural Problems & Poor Design:
- Tight Coupling: The code tightly couples the database access logic into functions (e.g.,
getCartTotal) that are almost entirely dependent on the PDO connection and table structure. This makes testing, maintenance, and scaling very difficult. - Redundant Queries: There's a significant amount of repetitive SQL queries. Fetching data once and reusing it is much more efficient than running multiple queries for the same information repeatedly.
neverreturn type injsonResponse(): This isn't valid PHP, this will throw an error.- Error Handling: The error handling (using exceptions) is basic but could be improved with logging and detailed error messages, especially in a production environment. Also, the 500 status code is good, but a more descriptive error message to the client would improve debugging.
- Magic Numbers/Values: Use constants or configuration files instead of hardcoded values (e.g.,
401,422).
3. Bugs & Edge Cases:
- Race Conditions: The cart update logic is susceptible to race conditions if multiple users attempt to add the same product to their carts simultaneously. Without proper locking or optimistic concurrency control, this can lead to duplicated items in the cart or incorrect stock levels.
- Cart Clearing Logic: The
DELETE FROM cart_itemsstatement at the end of the checkout doesn't seem necessary and might cause issues if a
Meta Llama 3.1 8B
Llama correctly recognized the need for prepared statements and raised several points around validation and error handling. However, much of the response concentrated on generic PHP recommendations rather than the behavior of this particular checkout.
Several observations were repeated, while some proposed fixes introduced errors of their own. More importantly, the central stock concurrency problem was not meaningfully solved.
This is exactly the distinction I wanted the experiment to expose. Recognizing insecure-looking code is not necessarily the same thing as understanding the system that code creates.
This PHP code contains several bugs, security problems, and architectural traps. I'll go through each issue, explain why it matters, and provide a corrected version.
1. Missing Input Validation
In the getCartTotal function, there's no validation for $userId. If an attacker sends a malicious user ID (e.g., a negative number or a string), it could lead to incorrect results or even SQL errors.
Solution: Add input validation to ensure $userId is a positive integer.
2. SQL Injection
In several places (e.g., getCartTotal, addProductToCart), user input is directly used in SQL queries without proper sanitization. This allows attackers to inject malicious SQL code.
Solution: Use prepared statements or parameterized queries instead of concatenating user input into the query.
3. Insecure Session Handling
The session_start() call is not validated to ensure it's started correctly.
Solution: Check if the session has already been started before trying to access it.
4. Missing Error Handling
In the getCartTotal function, if an error occurs during the query execution, it will not be caught or handled.
Solution: Wrap the query execution in a try-catch block to handle any potential errors.
5. Inconsistent Error Handling
The application uses a mix of jsonResponse and manual error handling (e.g., in the getCartTotal function). This can lead to confusion and inconsistent behavior.
Solution: Use a consistent approach for error handling throughout the application. For example, always use the jsonResponse function to handle errors.
6. Missing Validation for GET/POST Parameters
In several places (e.g., addProductToCart), GET and POST parameters are used without validation or sanitization.
Solution: Validate and sanitize all user input, including GET and POST parameters.
7. Inconsistent Database Operations
The application uses a mix of prepared statements and direct SQL queries.
Solution: Use consistent database operations throughout the application, such as using prepared statements for all queries.
8. Missing Validation for User ID
In several places (e.g., getCartTotal), the user ID is used without validation.
Solution: Validate the user ID to ensure it's a positive integer.
9. Inconsistent Error Messages
The application uses inconsistent error messages throughout.
Solution: Use consistent and descriptive error messages to make debugging easier.
10. Missing Validation for Product ID
In the addProductToCart function, the product ID is used without validation.
Solution: Validate the product ID to ensure it's a positive integer.
11. Inconsistent Database Transactions
The application uses inconsistent database transactions (e.g., in the checkout function).
Solution: Use consistent and robust database transactions throughout the application.
function checkout(PDO $pdo)
{
try {
// Transaction code here...
} catch (PDOException $e) {
$pdo->rollBack();
jsonResponset(['error' => 'Error processing order: ' . $e->getMessage()], 500);
return;
}
}
Qwen 3 8B
Qwen produced perhaps the most interesting failure in the experiment.
Its answer explicitly presented transactions as the solution to the concurrency problem. It moved the checkout operations into a transaction and described the result as providing atomicity and preventing inconsistent state.
But its corrected code still reads the stock normally, checks the value in PHP and later writes a newly calculated value. There is no locking read protecting the decision and no atomic conditional decrement.
In other words, it walked directly into the trap.
The answer looks better than the original. It uses prepared statements. The code is cleaner. The checkout has a transaction. The explanation confidently states that concurrency has been handled.
Yet the important race condition can remain.
For me, this is one of the most interesting results of the entire test because it demonstrates a problem that is difficult to detect by simply reading an AI answer and asking whether it “looks professional”.
To ensure the application is secure, efficient, and robust against concurrency and injection attacks, the following key improvements have been made:
✅ Security Fixes: Eliminate SQL Injection
All database queries now use prepared statements with parameterized placeholders instead of string interpolation. This prevents SQL injection attacks.
✅ Performance Optimization: Single Query for Cart Total
Instead of making multiple queries to fetch product details for each item in the cart, a single query joins cart_items with products to calculate the total. This reduces the number of database calls and improves performance.
✅ Concurrency Handling: Use Transactions
The checkout process is wrapped in a transaction to ensure atomicity. If any step fails, the entire operation is rolled back, preventing partial updates that could lead to inconsistent data.
✅ Input Validation and Sanitization
All user inputs are validated and sanitized to prevent malicious input from affecting database queries.
✅ Session Security: Secure Session Management
Session handling is assumed to be secure (e.g., using HTTPS, secure session cookies, and regenerating session IDs), which is beyond the scope of the current code but should be implemented in a production environment.
✅ Code Structure: Modular and Readable
The code is refactored for better readability and maintainability.
✅ Final Corrected Code
✅ Summary of Fixes
| Issue | Fix |
|---|---|
| SQL Injection | All queries now use prepared statements with placeholders |
| Inefficient Cart Total | Single JOIN query for cart total |
| Concurrency Issues | Transactions ensure atomic updates during checkout |
| Input Validation | Product IDs are validated to prevent invalid data |
| Security | No string interpolation in SQL queries |
✅ Recommendations for Further Improvement
- Use ORM or Query Builder: For better maintainability and security.
- Implement Rate Limiting: To prevent brute-force attacks on search or checkout.
- Add Logging: For auditing and debugging.
- Secure Session Management: Ensure session IDs are regenerated and cookies are secure.
This corrected code maintains the original functionality while addressing all critical security and performance issues.
The Most Dangerous AI Code May Be the Code That Looks Fixed
This experiment wasn't designed to prove that one AI is good and another is bad. The local models are much smaller than the large remote systems, and comparing them as though they were equivalent products wouldn't tell us very much.
What interests me is the pattern of failure.
Some models miss vulnerabilities. Some identify things that aren't actually bugs. Some understand a problem conceptually but fail to remove it from their generated code. Others focus on generic best practices while missing the interaction between database state and simultaneous requests.
That last category is particularly important as AI becomes increasingly involved in production development.
The danger isn't necessarily that an AI writes obviously terrible code.
The danger is that it writes code that is 90% better.
Prepared statements replace unsafe queries. Functions become cleaner. Transactions appear. Error handling improves. The explanation is convincing and the diff looks professional.
That remaining 10% can be exactly where the production failure lives.
Maybe We Need Different AI Coding Tests
Traditional coding benchmarks are useful for measuring whether a model can generate correct solutions to defined problems. But production development involves another skill: understanding systems that already exist.
That requires reasoning about state, databases, concurrency, security boundaries, external services, retries and business rules. Many of those problems cannot be understood by looking at one line in isolation.
A race condition may never appear during normal manual testing. It only exists when two correct executions happen at the same time.
That makes it an interesting test of AI reasoning because the model has to simulate something that isn't directly visible in the code.
This PHP experiment is obviously small and deliberately constructed. It isn't a scientific benchmark and I wouldn't pretend otherwise.
But it asks a question that I think will become increasingly important as developers rely more heavily on AI-generated fixes and AI code reviews.
We already know AI can write code.
The more interesting question is whether it can understand when apparently correct code will fail only after the real world starts using it.
And perhaps there is an even harder question after that:
Can AI recognize when its own fix only looks correct?