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.0.0(2d ago)00MITPHPPHP ^8.2CI passing

Since Jun 8Pushed 2d 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 2d ago

READMEChangelog (2)Dependencies (9)Versions (2)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 exact request arrives again — a retry after a timeout, a double-tapped button, a webhook redelivery — the original response is replayed instead of the action running twice.

Quickstart
----------

[](#quickstart)

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

Attach the middleware to the routes that create or mutate 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 from a fresh result. No key, no interception — existing callers keep working.

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

[](#the-problem)

`POST` is not safe to retry. When a client fires a write request and the connection drops before the response comes back, it has no way to know whether the server processed it. Both choices are bad: retry and you risk a duplicate charge, order, or signup; don't retry and 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 guarded 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 arrives later with a different payload, that is a client bug, and the request is rejected with `422` rather than silently returning the wrong cached response.
2. **Serialises concurrent duplicates with an atomic lock.** Two requests carrying the same key at the same time cannot both execute. The first takes the lock and runs; the second gets `409 Conflict` with a `Retry-After`header. The lock auto-expires, so a crashed worker never wedges a key.
3. **Replays the stored response.** Status code, body and a configurable set of headers are returned on subsequent hits — without touching your controller, queue jobs, or 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 `Cache::lock()`exposes. There are no migrations and no new tables.

Behaviour at a glance
---------------------

[](#behaviour-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 flight`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-executesRequirements
------------

[](#requirements)

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

[](#configuration)

The defaults are production-ready. Publish the config 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'),

    // Reject keyless requests on guarded routes with a 400 when true.
    'require_key' => false,

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

    // Cache store for stored 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),

    // Max time one request may hold its key's lock, in seconds.
    'lock_timeout' => 10,

    'max_key_length' => 255,

    // Namespace keys by authenticated user so callers can'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 onto the replayed response.
    'persist_headers' => ['Content-Type'],

    // Marker added to every guarded 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` off globally and opt individual routes in by flipping the config at the boundary, or set it to `true` if every guarded route must carry a key. With it on, a guarded request without the header is rejected with `400`before any work is done.

### Choosing a cache store

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

Replays are only as durable as the store behind them. `array` is for tests; in production point `IDEMPOTENCY_STORE` at `redis` (or any shared, persistent store with atomic locks) so replays survive across web workers and deploys. 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 hand-rolled approaches
--------------------------------------

[](#comparison-with-hand-rolled-approaches)

ApproachConcurrency-safePayload mismatch detectionReplays full responseMigrations`firstOrCreate` on a `request_id` columnNo (race between check and insert)NoNoYesUnique DB constraint + catch duplicatePartially (relies 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 gives the client an error instead of the original success. Idempotency at the HTTP boundary stops the second execution entirely and hands back 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

39

—

LowBetter than 84% of packages

Maintenance99

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity45

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

Unknown

Total

1

Last Release

2d 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 (4 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

3325.1M337](/packages/psalm-plugin-laravel)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[api-platform/laravel

API Platform support for Laravel

59156.3k10](/packages/api-platform-laravel)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

76318.2M110](/packages/laravel-mcp)[defstudio/telegraph

A laravel facade to interact with Telegram Bots

815320.5k3](/packages/defstudio-telegraph)[ralphjsmit/laravel-glide

Auto-magically generate responsive images from static image files.

4923.6k5](/packages/ralphjsmit-laravel-glide)

PHPackages © 2026

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