PHPackages                             webrek/laravel-idempotency - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. webrek/laravel-idempotency

ActiveLibrary[HTTP &amp; Networking](/categories/http)

webrek/laravel-idempotency
==========================

Safe request retries for Laravel APIs via the Idempotency-Key header.

v1.2.0(1mo ago)0419MITPHPPHP ^8.2CI passing

Since Jun 8Pushed 2w agoCompare

[ Source](https://github.com/webrek/laravel-idempotency)[ Packagist](https://packagist.org/packages/webrek/laravel-idempotency)[ Docs](https://github.com/webrek/laravel-idempotency)[ RSS](/packages/webrek-laravel-idempotency/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (19)Versions (4)Used By (0)

Laravel Idempotency
===================

[](#laravel-idempotency)

[![Latest Version on Packagist](https://camo.githubusercontent.com/4f0f80d87e6d5bb51c0a7d5c64bc3d9c5f365b311caed28c4053260c1b8a08f7/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f77656272656b2f6c61726176656c2d6964656d706f74656e63792e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/webrek/laravel-idempotency)[![Total Downloads](https://camo.githubusercontent.com/ee617a3a78bcba24827eee7e889c581a17d55b091ad015e7494934bee8a4e312/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f77656272656b2f6c61726176656c2d6964656d706f74656e63792e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/webrek/laravel-idempotency)[![Tests](https://camo.githubusercontent.com/b649c26b6dc6d3acb6d3758c06145d4fdce6e933ee687d39a9e09782d81ce91f/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f77656272656b2f6c61726176656c2d6964656d706f74656e63792f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/webrek/laravel-idempotency/actions/workflows/tests.yml)[![PHP Version](https://camo.githubusercontent.com/a5bb31ee58cb21ea4bf1fdee6f619861cecb3f13f58b6611778ec8e5ba9545d5/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f77656272656b2f6c61726176656c2d6964656d706f74656e63792e7376673f7374796c653d666c61742d737175617265)](https://php.net)[![License](https://camo.githubusercontent.com/697b35a22aceaee7d59845636e87cdd0ae23db304d73aa996c7a9261eff2bb68/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f77656272656b2f6c61726176656c2d6964656d706f74656e63792e7376673f7374796c653d666c61742d737175617265)](LICENSE)

Safe request retries for Laravel APIs. A client sends an `Idempotency-Key`header with a write request; if that same request arrives again—a retry after a timeout, a double-clicked button, a webhook redelivery—the original response is replayed instead of executing the action twice.

Quickstart
----------

[](#quickstart)

```
composer require webrek/laravel-idempotency
```

Attach the middleware to routes that create or modify state:

```
Route::post('/orders', [OrderController::class, 'store'])
    ->middleware('idempotency');
```

Clients opt in per request by sending a unique key:

```
POST /orders HTTP/1.1
Idempotency-Key: 0f8fad5b-d9cb-469f-a165-70867728950e
Content-Type: application/json

{"sku": "ABC-123", "qty": 2}
```

The first call runs the controller and stores the response. Any repeat of that call within the retention window returns the stored response verbatim, with an `Idempotency-Replayed: true` header so the client can tell a replay apart from a fresh result. Without a key there is no interception: existing callers keep working.

The problem
-----------

[](#the-problem)

`POST` is not safe to retry. When a client fires off a write request and the connection drops before the response comes back, it has no way of knowing whether the server processed it. Both options are bad: if you retry, you risk a duplicate charge, order, or record; if you don't retry, you risk silently losing the write.

Idempotency keys resolve the ambiguity. The client generates one key per logical operation and reuses it on every retry of that operation. The server promises that all requests sharing a key produce **one** execution and the **same**response. This is how Stripe, PayPal, Adyen, and most serious payment APIs make retries safe, and it is exactly what this package adds to your Laravel routes.

How it works
------------

[](#how-it-works)

The middleware sits in front of your protected routes and does four things:

1. **Fingerprints the request.** A SHA-256 of the method, path, and raw body is stored alongside the response. If the same key later arrives with a different payload, that is a client error, and the request is rejected with `422`instead of silently returning the wrong cached response.
2. **Serializes concurrent duplicates with an atomic lock.** Two requests carrying the same key at the same time cannot both run. The first takes the lock and executes; the second gets `409 Conflict` with a `Retry-After`header. The lock expires automatically, so a crashed worker never leaves a key stuck.
3. **Replays the stored response.** The status code, body, and a configurable set of headers are returned on subsequent hits, without touching your controller, your queued jobs, or your database.
4. **Leaves failures retryable.** Server errors (`5xx`) are never stored, so a client can safely retry after a transient failure. Successes and deterministic client errors are replayed.

Everything lives in Laravel's cache, using the same atomic locks that `Cache::lock()` exposes. There are no migrations and no new tables.

Behavior at a glance
--------------------

[](#behavior-at-a-glance)

ScenarioResultFirst request with a keyExecutes, stores the response, `Idempotency-Replayed: false`Same key, same payload, after completionReplays the stored response, `Idempotency-Replayed: true`Same key, same payload, still in progress`409 Conflict` + `Retry-After`Same key, **different** payload`422 Unprocessable Entity`No key (and `require_key` is false)Passes through untouched`GET` / `HEAD` requestIgnored: already safe to repeatResponse is `5xx`Not stored: the next attempt re-runs itRequirements
------------

[](#requirements)

ComponentVersionPHP8.2+Laravel12.x / 13.xCache storeAny store that supports atomic locks (redis, memcached, dynamodb, database, file, array)Configuration
-------------

[](#configuration)

The defaults are production-ready. Publish the configuration only if you need to change them:

```
php artisan vendor:publish --tag=idempotency-config
```

```
return [
    // Header clients send to identify a retryable operation.
    'header' => env('IDEMPOTENCY_HEADER', 'Idempotency-Key'),

    // Rejects keyless requests on protected routes with 400 when true.
    'require_key' => false,

    // HTTP methods the middleware protects. GET/HEAD are already safe.
    'methods' => ['POST', 'PUT', 'PATCH', 'DELETE'],

    // Cache store for responses and locks (null = default store).
    'store' => env('IDEMPOTENCY_STORE'),

    'prefix' => 'idempotency:',

    // How long a response stays replayable, in seconds.
    'ttl' => (int) env('IDEMPOTENCY_TTL', 86400),

    // Maximum time a request holds the lock for its key, in seconds.
    'lock_timeout' => 10,

    'max_key_length' => 255,

    // Scopes keys per authenticated user so callers don't collide.
    'scope_by_user' => true,

    // Null replays everything < 500; or list explicit codes, e.g. [200, 201, 422].
    'replay_status_codes' => null,

    // Headers copied to the replayed response.
    'persist_headers' => ['Content-Type'],

    // Flag added to every protected response: "true" | "false".
    'replay_header' => 'Idempotency-Replayed',
];
```

### Per-route retention

[](#per-route-retention)

Override the configured TTL (in seconds) for specific routes by passing it as a middleware parameter:

```
Route::post('/payments', ...)->middleware('idempotency:3600');   // 1 hour
Route::post('/imports', ...)->middleware('idempotency:86400');   // 1 day
```

### Replay event

[](#replay-event)

An `Idempotency\Events\IdempotentReplay` event is dispatched every time a stored response is replayed, so you can measure how many retries you are absorbing:

```
use Webrek\Idempotency\Events\IdempotentReplay;

Event::listen(IdempotentReplay::class, function (IdempotentReplay $event) {
    Metrics::increment('idempotency.replays', tags: ['key' => $event->key]);
});
```

### Requiring a key on specific routes

[](#requiring-a-key-on-specific-routes)

Leave `require_key` disabled globally and opt in individual routes by changing the configuration at the entry point, or set it to `true` if every protected route must carry a key. With this enabled, a protected request without the header is rejected with `400` before doing any work.

### Choosing a cache store

[](#choosing-a-cache-store)

Replays are only as durable as the store backing them. `array` is for testing; in production point `IDEMPOTENCY_STORE` at `redis` (or any shared, persistent store with atomic locks) so replays survive across web workers and deployments. A per-process store like `array` cannot coordinate locks across machines.

Client guidance
---------------

[](#client-guidance)

- **One key per logical operation, reused on retry.** Generate a UUID before the first attempt and send the *same* value on every retry of that attempt. A new key per retry defeats the purpose.
- **Handle `409` by backing off and retrying**: it means an earlier attempt is still running. Respect the `Retry-After` header.
- **Treat `422` as a bug on your side**: it means you reused a key for a genuinely different request.

Comparison with homegrown approaches
------------------------------------

[](#comparison-with-homegrown-approaches)

ApproachConcurrency-safeDetects different payloadReplays the full responseMigrations`firstOrCreate` on a `request_id` columnNo (race between the check and the insert)NoNoYesUnique DB constraint + catch duplicatePartially (depends on the write reaching the constrained table)NoNoYesThis packageYes (atomic lock)Yes (request fingerprint)YesNoA unique constraint stops a duplicate *row*, but it does not stop the duplicate side effects that ran before the insert (the email already sent, the third-party charge already made), and it hands the client an error instead of the original success. Idempotency at the HTTP boundary stops the second execution entirely and returns the first response.

Testing
-------

[](#testing)

```
composer install
composer test
```

The suite runs on the `array` cache store, so no external services are needed.

Contributing
------------

[](#contributing)

See [CONTRIBUTING.md](CONTRIBUTING.md).

Security
--------

[](#security)

Please review the [security policy](SECURITY.md) before reporting a vulnerability.

License
-------

[](#license)

The MIT License (MIT). See [LICENSE](LICENSE).

###  Health Score

44

—

FairBetter than 90% of packages

Maintenance94

Actively maintained with recent releases

Popularity18

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~4 days

Total

3

Last Release

38d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/7d8deca81629993819087597b5ad7695976b02e3d014f038e26e985f35f569de?d=identicon)[webrek](/maintainers/webrek)

---

Top Contributors

[![webrek](https://avatars.githubusercontent.com/u/5001338?v=4)](https://github.com/webrek "webrek (8 commits)")

---

Tags

apiidempotencylaravellaravel-packagemiddlewarephpwebhookshttpmiddlewareapilaravellaravel-packagewebhooksretryidempotencyidempotentexactly-once

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/webrek-laravel-idempotency/health.svg)

```
[![Health](https://phpackages.com/badges/webrek-laravel-idempotency/health.svg)](https://phpackages.com/packages/webrek-laravel-idempotency)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[api-platform/laravel

API Platform support for Laravel

58174.6k17](/packages/api-platform-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

77922.3M186](/packages/laravel-mcp)[defstudio/telegraph

A laravel facade to interact with Telegram Bots

813336.8k3](/packages/defstudio-telegraph)[simplestats-io/laravel-client

Server-side analytics for Laravel that follows the full funnel from visit to registration to payment, attributed to the channel that drove it. Revenue, MRR, churn and ad-spend profit (ROAS/CAC) per channel. GDPR compliant, ad-blocker proof.

5022.6k](/packages/simplestats-io-laravel-client)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
