check-idempotency
Idempotency Check
Analyze PHP code for idempotency violations that can cause duplicate writes, double charges, or inconsistent state on retries.
Detection Patterns
1. Missing Idempotency Key on POST/PUT Endpoints
<?php
declare(strict_types=1);
// BAD: POST endpoint creates resource without deduplication
final class CreateOrderAction
{
public function __invoke(Request $request): Response
{
$order = $this->orderService->create($request->validated());
return new JsonResponse($order, 201);
// Retry from client = duplicate order!
}
}
// GOOD: POST endpoint requires idempotency key
final class CreateOrderAction
{
public function __invoke(Request $request): Response
{
$idempotencyKey = $request->headers->get('Idempotency-Key');
if ($idempotencyKey === null) {
return new JsonResponse(['error' => 'Idempotency-Key header required'], 422);
}
$existing = $this->idempotencyStore->find($idempotencyKey);
if ($existing !== null) {
return new JsonResponse($existing->payload(), $existing->statusCode());
}
$order = $this->orderService->create($request->validated());
$this->idempotencyStore->save($idempotencyKey, $order, 201);
return new JsonResponse($order, 201);
}
}
2. Non-Idempotent Command Handlers
<?php
declare(strict_types=1);
// BAD: Handler executes without checking previous execution
final readonly class ChargePaymentHandler
{
public function __construct(
private PaymentGateway $gateway,
) {}
public function __invoke(ChargePaymentCommand $command): void
{
// If message is redelivered, payment is charged twice!
$this->gateway->charge($command->amount, $command->cardToken);
}
}
// GOOD: Handler checks for previous execution
final readonly class ChargePaymentHandler
{
public function __construct(
private PaymentGateway $gateway,
private ProcessedCommandStore $processedStore,
) {}
public function __invoke(ChargePaymentCommand $command): void
{
if ($this->processedStore->wasProcessed($command->commandId)) {
return; // Already executed, skip
}
$this->gateway->charge($command->amount, $command->cardToken);
$this->processedStore->markProcessed($command->commandId);
}
}
3. Duplicate Write Risk in Critical Operations
<?php
declare(strict_types=1);
// BAD: Payment without dedup guard
final readonly class PaymentService
{
public function charge(UserId $userId, Money $amount): PaymentResult
{
// No guard against duplicate charges
$result = $this->gateway->charge($userId->toString(), $amount->cents());
$this->repository->save(new Payment($userId, $amount, $result->transactionId()));
return $result;
}
}
// GOOD: Payment with unique constraint and idempotency
final readonly class PaymentService
{
public function charge(UserId $userId, Money $amount, string $requestId): PaymentResult
{
$existing = $this->repository->findByRequestId($requestId);
if ($existing !== null) {
return PaymentResult::fromExisting($existing);
}
$result = $this->gateway->charge(
$userId->toString(),
$amount->cents(),
idempotencyKey: $requestId,
);
$this->repository->save(
new Payment($userId, $amount, $result->transactionId(), $requestId),
);
return $result;
}
}
4. Retry-Unsafe Operations in Retry Loops
<?php
declare(strict_types=1);
// BAD: Email send in retry loop without idempotency guard
final readonly class NotificationService
{
public function sendWithRetry(Notification $notification): void
{
$attempts = 0;
while ($attempts < 3) {
try {
$this->mailer->send($notification->toEmail());
$this->smsService->send($notification->toSms());
return;
} catch (TransportException $e) {
$attempts++;
// Email might have been sent, SMS failed
// Retry sends email AGAIN!
}
}
}
}
// GOOD: Track each step independently with idempotency
final readonly class NotificationService
{
public function sendWithRetry(Notification $notification): void
{
$this->sendStep(
stepId: $notification->id() . ':email',
action: fn () => $this->mailer->send($notification->toEmail()),
);
$this->sendStep(
stepId: $notification->id() . ':sms',
action: fn () => $this->smsService->send($notification->toSms()),
);
}
private function sendStep(string $stepId, callable $action): void
{
if ($this->stepStore->isCompleted($stepId)) {
return;
}
$action();
$this->stepStore->markCompleted($stepId);
}
}
Grep Patterns
# POST/PUT actions without idempotency key check
Grep: "class.*Action|class.*Controller" --glob "**/*Action*.php"
Grep: "Idempotency-Key|idempotency_key|idempotencyKey" --glob "**/*.php"
# Command handlers without dedup check
Grep: "class.*Handler.*\{" --glob "**/*Handler*.php"
Grep: "wasProcessed|isProcessed|alreadyHandled" --glob "**/*Handler*.php"
# Payment/charge operations without idempotency
Grep: "->charge\(|->pay\(|->refund\(|->transfer\(" --glob "**/*.php"
Grep: "findByRequestId|findByIdempotencyKey" --glob "**/*.php"
# Retry loops with side effects
Grep: "while.*retry|for.*attempt|catch.*retry" --glob "**/*.php"
# Email/SMS in retry blocks
Grep: "->send\(.*Email|->send\(.*Sms|mailer->send" --glob "**/*.php"
# Missing unique constraints on write operations
Grep: "->save\(|->persist\(|->insert\(" --glob "**/*Handler*.php"
Severity Classification
| Pattern | Severity |
|---|---|
| Payment/charge without idempotency key | 🔴 Critical |
| Command handler without dedup check | 🔴 Critical |
| POST endpoint without Idempotency-Key | 🟠 Major |
| Email send in retry loop without guard | 🟠 Major |
| Write operation without unique constraint | 🟠 Major |
| Missing idempotency on non-critical updates | 🟡 Minor |
Output Format
### Idempotency Issue: [Brief Description]
**Severity:** 🔴/🟠/🟡
**Location:** `file.php:line`
**Type:** [Missing Key|Non-Idempotent Handler|Duplicate Write|Retry-Unsafe]
**Issue:**
[Description of the idempotency violation]
**Risk:**
- Duplicate charges/payments on retry
- Double email/SMS delivery
- Inconsistent state after network failure
**Code:**
```php
// Problematic pattern
Fix:
// With idempotency guard
## When This Is Acceptable
- **GET/DELETE requests** -- GET is inherently idempotent, DELETE on same resource is safe (returns 404 on retry)
- **Internal synchronous calls** -- Direct method calls within a single transaction boundary don't need idempotency keys
- **Upsert operations** -- INSERT ON CONFLICT UPDATE is inherently idempotent by design
- **Read-only commands** -- Query handlers that only read data don't need dedup checks
### False Positive Indicators
- Operation is wrapped in a database transaction with unique constraint
- Gateway already enforces idempotency (e.g., Stripe idempotency key at SDK level)
- Operation is naturally idempotent (setting a value, not incrementing)
More from dykyi-roman/awesome-claude-code
psr-overview-knowledge
PHP Standards Recommendations (PSR) overview knowledge base. Provides comprehensive reference for all accepted PSRs including PSR-1,3,4,6,7,11,12,13,14,15,16,17,18,20. Use for PSR selection decisions and compliance audits.
22detect-code-smells
Detects code smells in PHP codebases. Identifies God Class, Feature Envy, Data Clumps, Long Parameter List, Long Method, Primitive Obsession, Message Chains, Inappropriate Intimacy. Generates actionable reports with refactoring recommendations.
15clean-arch-knowledge
Clean Architecture knowledge base. Provides patterns, antipatterns, and PHP-specific guidelines for Clean Architecture and Hexagonal Architecture audits.
15ddd-knowledge
DDD architecture knowledge base. Provides patterns, antipatterns, and PHP-specific guidelines for Domain-Driven Design audits.
14testing-knowledge
Testing knowledge base for PHP 8.4 projects. Provides testing pyramid, AAA pattern, naming conventions, isolation principles, DDD testing guidelines, and PHPUnit patterns.
12bug-root-cause-finder
Root cause analysis methods for PHP bugs. Provides 5 Whys technique, fault tree analysis, git bisect guidance, and stack trace parsing.
12