{"id":50893067,"url":"https://github.com/webrek/laravel-idempotency","last_synced_at":"2026-06-15T22:02:17.364Z","repository":{"id":363237495,"uuid":"1262363907","full_name":"webrek/laravel-idempotency","owner":"webrek","description":"Safe request retries for Laravel APIs via the Idempotency-Key header.","archived":false,"fork":false,"pushed_at":"2026-06-08T02:33:11.000Z","size":30,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-08T04:20:31.408Z","etag":null,"topics":["api","idempotency","laravel","laravel-package","middleware","php","webhooks"],"latest_commit_sha":null,"homepage":null,"language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/webrek.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-07T22:53:54.000Z","updated_at":"2026-06-08T02:33:15.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/webrek/laravel-idempotency","commit_stats":null,"previous_names":["webrek/laravel-idempotency"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/webrek/laravel-idempotency","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webrek%2Flaravel-idempotency","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webrek%2Flaravel-idempotency/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webrek%2Flaravel-idempotency/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webrek%2Flaravel-idempotency/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/webrek","download_url":"https://codeload.github.com/webrek/laravel-idempotency/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/webrek%2Flaravel-idempotency/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34381762,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-15T02:00:07.085Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","idempotency","laravel","laravel-package","middleware","php","webhooks"],"created_at":"2026-06-15T22:02:16.327Z","updated_at":"2026-06-15T22:02:17.354Z","avatar_url":"https://github.com/webrek.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Laravel Idempotency\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/webrek/laravel-idempotency.svg?style=flat-square)](https://packagist.org/packages/webrek/laravel-idempotency)\n[![Total Downloads](https://img.shields.io/packagist/dt/webrek/laravel-idempotency.svg?style=flat-square)](https://packagist.org/packages/webrek/laravel-idempotency)\n[![Tests](https://img.shields.io/github/actions/workflow/status/webrek/laravel-idempotency/tests.yml?branch=main\u0026label=tests\u0026style=flat-square)](https://github.com/webrek/laravel-idempotency/actions/workflows/tests.yml)\n[![PHP Version](https://img.shields.io/packagist/php-v/webrek/laravel-idempotency.svg?style=flat-square)](https://php.net)\n[![License](https://img.shields.io/packagist/l/webrek/laravel-idempotency.svg?style=flat-square)](LICENSE)\n\nSafe request retries for Laravel APIs. A client sends an `Idempotency-Key`\nheader with a write request; if that exact request arrives again — a retry\nafter a timeout, a double-tapped button, a webhook redelivery — the original\nresponse is replayed instead of the action running twice.\n\n## Quickstart\n\n```bash\ncomposer require webrek/laravel-idempotency\n```\n\nAttach the middleware to the routes that create or mutate state:\n\n```php\nRoute::post('/orders', [OrderController::class, 'store'])\n    -\u003emiddleware('idempotency');\n```\n\nClients opt in per request by sending a unique key:\n\n```http\nPOST /orders HTTP/1.1\nIdempotency-Key: 0f8fad5b-d9cb-469f-a165-70867728950e\nContent-Type: application/json\n\n{\"sku\": \"ABC-123\", \"qty\": 2}\n```\n\nThe first call runs the controller and stores the response. Any repeat of that\ncall within the retention window returns the stored response verbatim, with an\n`Idempotency-Replayed: true` header so the client can tell a replay from a fresh\nresult. No key, no interception — existing callers keep working.\n\n## The problem\n\n`POST` is not safe to retry. When a client fires a write request and the\nconnection drops before the response comes back, it has no way to know whether\nthe server processed it. Both choices are bad: retry and you risk a duplicate\ncharge, order, or signup; don't retry and you risk silently losing the write.\n\nIdempotency keys resolve the ambiguity. The client generates one key per logical\noperation and reuses it on every retry of that operation. The server promises\nthat all requests sharing a key produce **one** execution and the **same**\nresponse. This is how Stripe, PayPal, Adyen and most serious payment APIs make\nretries safe — and it is exactly what this package adds to your Laravel routes.\n\n## How it works\n\nThe middleware sits in front of your guarded routes and does four things:\n\n1. **Fingerprints the request.** A SHA-256 of the method, path and raw body is\n   stored alongside the response. If the same key arrives later with a different\n   payload, that is a client bug, and the request is rejected with `422` rather\n   than silently returning the wrong cached response.\n2. **Serialises concurrent duplicates with an atomic lock.** Two requests\n   carrying the same key at the same time cannot both execute. The first takes\n   the lock and runs; the second gets `409 Conflict` with a `Retry-After`\n   header. The lock auto-expires, so a crashed worker never wedges a key.\n3. **Replays the stored response.** Status code, body and a configurable set of\n   headers are returned on subsequent hits — without touching your controller,\n   queue jobs, or database.\n4. **Leaves failures retryable.** Server errors (`5xx`) are never stored, so a\n   client can safely retry after a transient failure. Successes and\n   deterministic client errors are replayed.\n\nEverything lives in Laravel's cache, using the same atomic locks `Cache::lock()`\nexposes. There are no migrations and no new tables.\n\n## Behaviour at a glance\n\n| Scenario | Result |\n| --- | --- |\n| First request with a key | Executes, stores the response, `Idempotency-Replayed: false` |\n| Same key, same payload, after completion | Replays the stored response, `Idempotency-Replayed: true` |\n| Same key, same payload, still in flight | `409 Conflict` + `Retry-After` |\n| Same key, **different** payload | `422 Unprocessable Entity` |\n| No key (and `require_key` is false) | Passes through untouched |\n| `GET` / `HEAD` request | Ignored — already safe to repeat |\n| Response is `5xx` | Not stored — the next attempt re-executes |\n\n## Requirements\n\n| Component | Version |\n| --------- | ------- |\n| PHP | 8.2+ |\n| Laravel | 12.x |\n| Cache store | Any store that supports atomic locks (redis, memcached, dynamodb, database, file, array) |\n\n## Configuration\n\nThe defaults are production-ready. Publish the config only if you need to change\nthem:\n\n```bash\nphp artisan vendor:publish --tag=idempotency-config\n```\n\n```php\nreturn [\n    // Header clients send to identify a retryable operation.\n    'header' =\u003e env('IDEMPOTENCY_HEADER', 'Idempotency-Key'),\n\n    // Reject keyless requests on guarded routes with a 400 when true.\n    'require_key' =\u003e false,\n\n    // HTTP methods the middleware guards. GET/HEAD are already safe.\n    'methods' =\u003e ['POST', 'PUT', 'PATCH', 'DELETE'],\n\n    // Cache store for stored responses and locks (null = default store).\n    'store' =\u003e env('IDEMPOTENCY_STORE'),\n\n    'prefix' =\u003e 'idempotency:',\n\n    // How long a response stays replayable, in seconds.\n    'ttl' =\u003e (int) env('IDEMPOTENCY_TTL', 86400),\n\n    // Max time one request may hold its key's lock, in seconds.\n    'lock_timeout' =\u003e 10,\n\n    'max_key_length' =\u003e 255,\n\n    // Namespace keys by authenticated user so callers can't collide.\n    'scope_by_user' =\u003e true,\n\n    // Null replays everything \u003c 500; or list explicit codes, e.g. [200, 201, 422].\n    'replay_status_codes' =\u003e null,\n\n    // Headers copied onto the replayed response.\n    'persist_headers' =\u003e ['Content-Type'],\n\n    // Marker added to every guarded response: \"true\" | \"false\".\n    'replay_header' =\u003e 'Idempotency-Replayed',\n];\n```\n\n### Per-route retention\n\nOverride the configured TTL (in seconds) for specific routes by passing it as a\nmiddleware parameter:\n\n```php\nRoute::post('/payments', ...)-\u003emiddleware('idempotency:3600');   // 1 hour\nRoute::post('/imports', ...)-\u003emiddleware('idempotency:86400');   // 1 day\n```\n\n### Replay event\n\nAn `Idempotency\\Events\\IdempotentReplay` event is dispatched every time a stored\nresponse is replayed, so you can measure how many retries you are absorbing:\n\n```php\nuse Webrek\\Idempotency\\Events\\IdempotentReplay;\n\nEvent::listen(IdempotentReplay::class, function (IdempotentReplay $event) {\n    Metrics::increment('idempotency.replays', tags: ['key' =\u003e $event-\u003ekey]);\n});\n```\n\n### Requiring a key on specific routes\n\nLeave `require_key` off globally and opt individual routes in by flipping the\nconfig at the boundary, or set it to `true` if every guarded route must carry a\nkey. With it on, a guarded request without the header is rejected with `400`\nbefore any work is done.\n\n### Choosing a cache store\n\nReplays are only as durable as the store behind them. `array` is for tests; in\nproduction point `IDEMPOTENCY_STORE` at `redis` (or any shared, persistent store\nwith atomic locks) so replays survive across web workers and deploys. A\nper-process store like `array` cannot coordinate locks across machines.\n\n## Client guidance\n\n- **One key per logical operation, reused on retry.** Generate a UUID before the\n  first attempt and send the *same* value on every retry of that attempt. A new\n  key per retry defeats the purpose.\n- **Handle `409` by backing off and retrying** — it means an earlier attempt is\n  still running. Respect the `Retry-After` header.\n- **Treat `422` as a bug on your side** — it means you reused a key for a\n  genuinely different request.\n\n## Comparison with hand-rolled approaches\n\n| Approach | Concurrency-safe | Payload mismatch detection | Replays full response | Migrations |\n| --- | --- | --- | --- | --- |\n| `firstOrCreate` on a `request_id` column | No (race between check and insert) | No | No | Yes |\n| Unique DB constraint + catch duplicate | Partially (relies on the write reaching the constrained table) | No | No | Yes |\n| This package | Yes (atomic lock) | Yes (request fingerprint) | Yes | No |\n\nA unique constraint stops a duplicate *row*, but it does not stop the duplicate\nside effects that ran before the insert (the email already sent, the third-party\ncharge already made), and it gives the client an error instead of the original\nsuccess. Idempotency at the HTTP boundary stops the second execution entirely\nand hands back the first response.\n\n## Testing\n\n```bash\ncomposer install\ncomposer test\n```\n\nThe suite runs on the `array` cache store, so no external services are needed.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Security\n\nPlease review the [security policy](SECURITY.md) before reporting a\nvulnerability.\n\n## License\n\nThe MIT License (MIT). See [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwebrek%2Flaravel-idempotency","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwebrek%2Flaravel-idempotency","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwebrek%2Flaravel-idempotency/lists"}