PHPackages                             langsys/laravel-request-query-cache - 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. [Database &amp; ORM](/categories/database)
4. /
5. langsys/laravel-request-query-cache

ActiveLibrary[Database &amp; ORM](/categories/database)

langsys/laravel-request-query-cache
===================================

Request caching toolkit for Laravel: per-request Eloquent query deduplication (firstCached/getCached) plus idempotent HTTP responses via the idempotent middleware.

v1.2.0(1mo ago)05MITPHPPHP ^8.2

Since Jun 9Pushed 1mo agoCompare

[ Source](https://github.com/langsys/laravel-request-query-cache)[ Packagist](https://packagist.org/packages/langsys/laravel-request-query-cache)[ RSS](/packages/langsys-laravel-request-query-cache/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (8)Versions (5)Used By (0)

Laravel Request Query Cache
===========================

[](#laravel-request-query-cache)

A request caching toolkit for Laravel with two independent features:

1. **Per-request query deduplication** — `firstCached()` / `getCached()` macros that run a query **once per request** and serve identical repeats from an in-memory store flushed when the request ends. *(Not a persistent cache.)*
2. **Idempotent HTTP responses** — an `idempotent` middleware that replays the stored response for a repeated `Idempotency-Key` instead of executing the route again. *(Persistent: uses your configured cache store.)*

The two share nothing but the package — pick either, or both. Query dedup needs no config; idempotency is opt-in per route.

Why would I want this?
----------------------

[](#why-would-i-want-this)

The single best use case is **a query you run to validate input that you then need again downstream.**

Validation rules and controllers naturally re-express the same query. A rule fetches a row to check it exists / is in the right state; then the controller (or service) fetches that same row to actually do the work. That's two identical round trips to the database for one logical lookup.

The usual workarounds are awkward: smuggle the already-fetched model out of the rule into the controller, or skip the rule and re-validate inline in the controller. With `firstCached()`/`getCached()` you don't have to. Both layers just write the natural query — identical SQL + bindings hit the database once, and the controller gets the row the rule already loaded.

The goal: **zero validation in the controller/service layer** — validation stays in the rule where it belongs, and the controller reuses the query for free.

### Example: a custom rule and a controller sharing one query

[](#example-a-custom-rule-and-a-controller-sharing-one-query)

A vanilla Laravel validation rule that runs a query:

```
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class PendingInvitation implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $invitation = UserInvitation::where('activation_token', $value)
            ->whereNull('redeemed_at')
            ->firstCached();

        if (! $invitation) {
            $fail('This invitation is invalid or has already been used.');
        }
    }
}
```

The controller validates, then reuses the **exact same query** — no second DB hit, no model smuggled out of the rule, no inline re-validation:

```
public function store(Request $request)
{
    $request->validate([
        'token' => ['required', new PendingInvitation],
    ]);

    // Identical SQL + bindings → served from the per-request cache.
    $invitation = UserInvitation::where('activation_token', $request->token)
        ->whereNull('redeemed_at')
        ->firstCached();

    $invitation->redeem($request->user());

    return response()->json($invitation);
}
```

The rule has already done the DB work; the controller's query resolves from the in-memory store. The only requirement is that both queries are identical — same `where`/`whereNull` clauses in the same order, so they produce the same SQL and bindings.

Installation
------------

[](#installation)

```
composer require langsys/laravel-request-query-cache
```

The service provider is auto-discovered. No configuration required.

Usage
-----

[](#usage)

```
// Eloquent collection — caches ->get()
$locales = Locale::query()->getCached();

// Single model — caches ->first()
$invitation = UserInvitation::where('activation_token', $token)
    ->where('user_id', null)
    ->firstCached();
```

If the same query (identical SQL and bindings) runs again during the same request, it returns the stored result without touching the database.

```
$a = User::where('id', 1)->firstCached(); // hits the DB
$b = User::where('id', 1)->firstCached(); // served from cache, no DB hit
// $a === $b
```

Different queries are cached independently — bindings are part of the cache key, so `where('id', 1)` and `where('id', 2)` never collide.

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

[](#how-it-works)

- A `RequestQueryCache` singleton holds an in-memory `array` keyed by `md5(sql + serialized bindings)`.
- `getCached()` wraps `->get()`; `firstCached()` wraps `->first()` (and prefixes its key with `first:` so the two never collide on the same query).
- The store is flushed on `app.terminating` (covers PHP-FPM) **and** on Octane's `RequestReceived` event when running under [Laravel Octane](https://laravel.com/docs/octane), guaranteeing every request starts with an empty store.

Caveat: writes within the same request
--------------------------------------

[](#caveat-writes-within-the-same-request)

Because results are memoized on SQL + bindings, if you write to a row and then re-query it with `firstCached()`/`getCached()` in the **same request**, you get the pre-write cached value. Use the uncached `first()`/`get()` after a write you need to read back in-request.

Idempotent HTTP responses
-------------------------

[](#idempotent-http-responses)

State-changing endpoints (payments, orders, sign-ups) get retried — by impatient users, flaky networks, and queue workers. The `idempotent` middleware makes those retries safe: the client sends a unique `Idempotency-Key` header, and any repeat of that key replays the **original** response instead of running the route twice.

```
use App\Http\Controllers\PaymentController;

Route::post('/payments', [PaymentController::class, 'store'])
    ->middleware('idempotent');
```

```
POST /payments
Idempotency-Key: 7f3c…              # client-generated, unique per logical operation

→ first call:  runs the controller, stores the response
→ same key:    replays the stored response  (+ Idempotency-Replayed: true)
```

### How a request is handled

[](#how-a-request-is-handled)

1. Only `POST`/`PUT`/`PATCH` are guarded (configurable). Everything else passes through.
2. No key present → `400` if the route requires it, otherwise passes through.
3. Key seen before, **same** request → the stored response is replayed.
4. Key seen before, **different** request body → `422` (the key was reused for something else — a client bug you want surfaced, not silently mishandled).
5. Key currently **in flight** (a concurrent duplicate) → `409` + `Retry-After: 1`. An atomic lock guarantees the route body runs at most once even under a simultaneous double-submit.

A request's identity (its *fingerprint*) is the HTTP method + route + path parameters + query string + body. Body field order doesn't matter — `{"a":1,"b":2}` and `{"b":2,"a":1}` are the same request. Reusing one key across different path parameters (e.g. `/projects/A` vs `/projects/B`) is treated as misuse and returns `422`. The key is namespaced by **scope** so two callers can use the same key without colliding.

**Scopes** — `user | ip | global | apikey`:

- `user` — per authenticated user; falls back to the request IP when there is no session user.
- `ip` — per client IP.
- `global` — one key space shared by everyone.
- `apikey` — per tenant for API-key auth (where there is no session user). It reads the request attribute named by `scope_attribute` (default `api_key_id`), which your auth middleware sets, and falls back to user/IP when that attribute is absent: ```
    // in your API-key auth middleware, before the idempotent middleware runs
    $request->attributes->set('api_key_id', $apiKey->id);
    ```

### Per-route overrides

[](#per-route-overrides)

Override `ttl`, `required`, and `scope` inline — `idempotent:{ttl},{required},{scope}`:

```
// 24h window, header mandatory, scoped per authenticated user
Route::post('/payments', …)->middleware('idempotent:86400,true,user');

// 5-minute window, optional, global (one key space for everyone)
Route::post('/webhooks/stripe', …)->middleware('idempotent:300,false,global');

// 1h window, optional, per API-key tenant (SDK endpoints with no session user)
Route::post('/translate', …)->middleware('idempotent:3600,false,apikey');
```

### Configuration

[](#configuration)

Defaults work out of the box. To customize, publish the config:

```
php artisan vendor:publish --tag=request-query-cache-config
```

```
// config/request-query-cache.php → 'idempotency'
'enabled'      => true,
'store'        => null,              // null = default cache store
'header'       => 'Idempotency-Key',
'ttl'          => 86400,            // seconds a response stays replayable (24h)
'required'     => false,            // 400 when the header is missing
'scope'        => 'user',           // user | ip | global | apikey
'scope_attribute' => 'api_key_id', // request attribute the apikey scope reads
'lock_timeout' => 10,              // seconds the in-flight lock is held
'methods'      => ['POST', 'PUT', 'PATCH'],
'replay_header'=> 'Idempotency-Replayed',  // null to disable
```

**TTL guidance:** default to 24h for money/order endpoints (a retry hours later must not double-charge — same window Stripe uses); drop to minutes for cheap, high-volume endpoints.

### Requirements &amp; caveats

[](#requirements--caveats)

- **The cache store must support atomic locks** — `redis`, `memcached`, `dynamodb`, `database`, `file`, or `array`. Set a specific store via the `store` config key if your default doesn't. (If the store can't lock, the middleware still replays but loses the concurrent-duplicate `409` guarantee.)
- **`5xx` responses are never stored** — a transient server error must re-execute on retry, not replay forever. `2xx`–`4xx` responses are stored.
- **Body-sensitive by design** — reusing a key with a changed payload is a `422`, not a silent overwrite. That's the safety guarantee.
- Streamed/binary (non-string-body) responses are passed through unstored.

The middleware also exposes `$request->attributes->get('idempotent')` (bool) and `'idempotency-key'` to downstream code.

> **Note:** attribute-style usage (`#[Idempotent]` on a controller method) is not wired up yet — use the `idempotent` middleware alias or class for now.

Testing
-------

[](#testing)

```
composer install
vendor/bin/phpunit
```

License
-------

[](#license)

MIT

###  Health Score

40

—

FairBetter than 86% of packages

Maintenance91

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community7

Small or concentrated contributor base

Maturity49

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 ~1 days

Total

3

Last Release

42d ago

### Community

Maintainers

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

![](https://www.gravatar.com/avatar/02a940869a3d978165d8f017b8eeb6d6141c10d5c56f083fe16433a42556191d?d=identicon)[gcapra](/maintainers/gcapra)

---

Top Contributors

[![gcapra](https://avatars.githubusercontent.com/u/3620327?v=4)](https://github.com/gcapra "gcapra (5 commits)")

---

Tags

middlewarelaraveleloquentcachequeryidempotencyidempotentmemoizeper-request

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/langsys-laravel-request-query-cache/health.svg)

```
[![Health](https://phpackages.com/badges/langsys-laravel-request-query-cache/health.svg)](https://phpackages.com/packages/langsys-laravel-request-query-cache)
```

###  Alternatives

[mongodb/laravel-mongodb

A MongoDB based Eloquent model and Query builder for Laravel

7.1k8.4M98](/packages/mongodb-laravel-mongodb)[kirschbaum-development/eloquent-power-joins

The Laravel magic applied to joins.

1.6k32.6M46](/packages/kirschbaum-development-eloquent-power-joins)[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[yajra/laravel-oci8

Oracle DB driver for Laravel via OCI8

8793.2M25](/packages/yajra-laravel-oci8)[spiritix/lada-cache

A Redis based, automated and scalable database caching layer for Laravel

592456.3k2](/packages/spiritix-lada-cache)[glushkovds/phpclickhouse-laravel

Adapter of the most popular library https://github.com/smi2/phpClickHouse to Laravel

2051.5M2](/packages/glushkovds-phpclickhouse-laravel)

PHPackages © 2026

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