> For the complete documentation index, see [llms.txt](https://core-foundation-doc.rupeshstha.com.np/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://core-foundation-doc.rupeshstha.com.np/database-and-repositories/caching.md).

# Caching Strategy

Performance is a core pillar of Core Foundation. The repository system provides a **Scoped Hierarchical Caching** strategy designed for high-scale environments.

## Core Concepts

The system avoids global cache flushes by using a two-tier tagging system isolated by an isolation boundary (Scope).

### 1. Isolation (CacheScope)

Isolation ensures that operations in one scope (e.g. a Tenant) never affect another.

**How to implement:** In your repository, override `cacheScope()` to return a `PrefixCacheScope`.

```php
class ProductRepository extends BaseRepository
{
    protected function cacheScope(): ?CacheScope
    {
        // Example: scoping by tenant
        return new PrefixCacheScope("tenant:" . tenant()->id);
    }
}
```

### 2. Intra-Tenant Granularity (Two-Tier Tags)

We distinguish between listing results and individual records to maximize cache hits.

1. **Listing Tier:** (`{scope}:{table}:listing`) - Used for `fetchAll`, search, and filters.
2. **Record Tier:** (`{scope}:{table}:record:{id}`) - Used for `fetchById`.

## Automatic Invalidation

By registering the `RepositoryCacheObserver` on your models, invalidation happens surgically.

### Configuring the Resolver

Since CoreFoundation is tenancy-agnostic, you must tell the observer how to resolve the scope for a model in your `AppServiceProvider`.

```php
// In AppServiceProvider::boot():
RepositoryCacheObserver::resolveScopeUsing(function ($model) {
    return $model->tenant_id ? new PrefixCacheScope("tenant:{$model->tenant_id}") : null;
});
```

### Registering the Observer

```php
// In your ServiceProvider::boot():
Product::observe(RepositoryCacheObserver::class);
```

| Operation   | Invalidation Scope   | Tier(s) Flushed                            |
| ----------- | -------------------- | ------------------------------------------ |
| **Create**  | **Listing Tier**     | New items appear in lists.                 |
| **Restore** | **Listing Tier**     | Soft-deleted items reappear.               |
| **Update**  | **Record + Listing** | Data changed; position in list may change. |
| **Delete**  | **Record + Listing** | Data gone; list must be updated.           |

### Relation-Aware Invalidation

A cached `Post::with('comments')->fetchAll()` result embeds Comment data — so a write to Comment must invalidate it too, even though the write happened through `CommentRepository`, not `PostRepository`. This happens automatically:

```php
$postRepository->with('comments')->fetchAll();        // tagged with comments' real table tag
$commentRepository->create([...]);                     // busts that same tag — Post cache invalidated
```

**Only relations actually passed to `with()`/`relations:` are tagged** — a query that never eager-loads `comments` is untouched by Comment writes. The tag is resolved from the *actual* relation method (`getRelated()->getTable()`), not guessed from the relation's name, so it's correct regardless of singular/plural naming conventions, and it's scope-prefixed to match the query it's attached to — a tenant-scoped query's relation tags never cross into another tenant's invalidation.

## Cache Warming

For expensive computations or frequently accessed data, you can proactively populate the cache using **Warmers**.

### 1. Define a Warmer

Implement the `CacheWarmer` interface.

```php
class ShippingRatesWarmer implements CacheWarmer
{
    public function name(): string => 'shipping-rates';

    public function warm(array $context = []): void
    {
        $tenantId = $context['tenant_id'];
        // Logic to pre-calculate and cache rates
    }
}
```

### 2. Trigger Warming

Warmers are designed to be run as background jobs via the Artisan command.

```bash
# Warm everything (dispatches parallel background jobs)
php artisan core:warm-cache

# Warm a specific tenant
php artisan core:warm-cache --tenant=5
```

## Service-Layer Synchronization

Services can link their cache lifetime to these repository tags using `CacheDependency`.

```php
class PricingService extends BaseService
{
    public function getPrice(Product $product, CacheScope $scope)
    {
        return $this->rememberWithDependencies(
            key: "price:{$product->id}",
            dependencies: [CacheDependency::onRecord($product, $product->id, $scope)],
            callback: fn() => $this->calculate($product)
        );
    }
}
```

When the product is updated via the repository, **both** the repository cache and this service cache will be invalidated simultaneously.

`CacheDependency` offers three constructors, matching the repository's own tiers:

| Constructor                       | Depends on                    | A write to a record NOT in the set... |
| --------------------------------- | ----------------------------- | ------------------------------------- |
| `onRecord($model, $id, $scope)`   | One specific record           | ...never busts it.                    |
| `onRecords($model, $ids, $scope)` | A known, fixed set of records | ...never busts it.                    |
| `onListing($model, $scope)`       | Every record of that model    | ...busts it.                          |

## Choosing Invalidation Granularity

**This is not a bug — read before "fixing" it.** A single `update()` on *any* record busts the entire listing tier for that model+scope — every cached `fetchAll()` result, not just the one page that happened to contain the changed record. Updating 1 of 1,000 cached products invalidates the cached *listing* of all 1,000, and that's correct: a listing's cache key is a hash of criteria/relations/columns, and there's no way to know in advance which cached listing variants (different filters, sorts, pages) happen to include the record that just changed. The only options are "bust every listing variant" or "risk serving stale data in some of them" — and stale data is the worse failure mode. Don't try to make the listing tier smarter; choose the record tier instead when your access pattern is actually per-item.

**If your cached read is a listing** — any filter/sort/pagination combination over a model — accept that any write to that model busts it. This is `fetchAll()`'s default behavior, and it's correct.

**If your access pattern is actually per-item** — a shop's balance, a single product's detail page — use `asRecord($id)` (repository) or `CacheDependency::onRecord()`/`onRecords()` (service) so only writes to *that* record bust it:

```php
// 1,000 products cached individually, each tagged with its own id.
// Updating product #501 busts ONLY product #501's cache — the other 999
// stay warm. This is the record tier working as intended.
public function getProductSummary(int $productId): array
{
    return $this->cacheQuery(__FUNCTION__)
        ->asRecord($productId)
        ->remember(fn () => $this->computeSummary($productId));
}
```

**The same trap exists at the service layer, and it's easier to fall into by accident.** If you need per-item pricing for 1,000 products and cache it as one service-level computation "over the whole catalog," you've recreated the listing tier's blast radius by hand — one product update busts your entire cached result:

```php
// Wrong — depends on the whole catalog for a computation that only
// actually reads three products. Any product write busts this, including
// the other 997 that were never part of the bundle.
return $this->rememberWithDependencies(
    key: 'bundle:'.implode(',', $productIds),
    dependencies: [CacheDependency::onListing($this->product, $scope)],
    callback: fn () => $this->computePrice($productIds),
);

// Correct — depends on exactly the records the computation touched.
// A write to product #999 (not in the bundle) never busts this.
return $this->rememberWithDependencies(
    key: 'bundle:'.implode(',', $productIds),
    dependencies: CacheDependency::onRecords($this->product, $productIds, $scope),
    callback: fn () => $this->computePrice($productIds),
);
```

**A cached computation that genuinely depends on many records at once** — a true aggregate like "average price across all 1,000 products," not "these 1,000 products individually" — is exactly what `onListing()` is for. Busting on every write there isn't over-invalidation; the computation legitimately depends on every record, so a stale value would be a real bug.

## Driver Requirement

**Tag-based caching requires `array`, `redis`, or `memcached`.** `file` and `database` do not implement `Cache::tags()` at all. Use `array` for local development and tests only — it isn't shared across processes or workers, so it's never correct for production.

`CoreFoundationServiceProvider` checks `config('cache.default')` at boot and throws an `InvalidArgumentException` immediately if it isn't tag-capable, naming the exact config key to fix — not a `BadMethodCallException` from deep inside Laravel's cache internals the first time a repository runs. Set `core-foundation.cache.global` to `false` to disable repository caching entirely instead of switching drivers.

***

## Frontend Cache Bridge

The three pillars above handle server-side cache coherence. The **Frontend Cache Bridge** closes the loop with the client — it tells the frontend exactly which cached queries are stale after every mutation, enabling automatic, surgical cache invalidation without any manual wiring in application code.

### The Problem Without It

Most frontend caching libraries (React Query, SWR) require you to manually declare what to invalidate after each mutation:

```typescript
// Without the bridge — you write this in every mutation handler
useMutation({
    mutationFn: updateProduct,
    onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ['products'] });
        queryClient.invalidateQueries({ queryKey: ['product', id] });
        queryClient.invalidateQueries({ queryKey: ['featured-products'] });
        // Did you remember every query that embeds product data? Probably not.
    },
});
```

This creates tight coupling between frontend mutation logic and backend data relationships. Miss one and you serve stale data.

### How the Bridge Works

CoreFoundation collects every cache tag busted during a request and emits them as response headers. The frontend reads those headers and invalidates exactly the right queries automatically.

```
POST /api/products/42   (update a product)
    ↓
ProductRepository::update() runs
    → Redis flushes: tenant:1:products:record:42, tenant:1:products:listing
    → CacheBustCollector records those tags
    ↓
Response returns with headers:
    X-Cache-Tags-Busted: tenant:1:products:record:42,tenant:1:products:listing
```

### The Two Headers

| Header                    | When                                    | Meaning                                       |
| ------------------------- | --------------------------------------- | --------------------------------------------- |
| `X-Cache-Tags-Busted`     | Any write that flushes cache            | Comma-separated list of busted tags           |
| `X-Cache-Full-Bust: true` | More than 50 tags busted in one request | Invalidate everything — too many to enumerate |

Both headers can appear together on the same response. The cap of 50 prevents HTTP header bloat (most proxies and nginx enforce \~8KB header limits). A bulk delete operation that busts 200 records triggers `X-Cache-Full-Bust: true`; the frontend treats the entire cache as stale.

### Registration

Add `AttachCacheHeaders` to the API middleware group. Nothing is emitted if the middleware is not registered — it is fully opt-in.

```php
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('api', \CoreFoundation\Http\Middlewares\AttachCacheHeaders::class);
})
```

### Frontend Integration

#### Using `@rupeshstha/core-foundation-react` (recommended for React/TanStack Query)

The SDK's `CacheTagRegistry` implements exactly this bridge — wire it once at app startup and every `useApiQuery`/`useApiMutation` call gets automatic, tag-based invalidation with no per-mutation code:

```typescript
import { ApiClient, CacheTagRegistry } from '@rupeshstha/core-foundation-react';

const registry = new CacheTagRegistry();
const client = new ApiClient({
    baseUrl,
    getToken,
    onCacheTagsBusted: (tags) => registry.invalidate(tags, queryClient),
    onCacheFullBust: () => queryClient.invalidateQueries(),
});

useApiQuery({
    queryKey: ['products'],
    queryFn: () => client.get('/products'),
    cacheTags: [`tenant:${tenantId}:products:listing`],
    registry,
});
```

See [Cache Invalidation](/frontend-sdk/cache-invalidation.md) for the full mechanism, including how query keys map to tags and when to prefer explicit `bustTags` over the header-driven path.

#### Rolling Your Own (any other client)

If you're not using `@rupeshstha/core-foundation-react` — a different framework, or a bare fetch/axios setup — the same mechanism is straightforward to replicate. A single response interceptor handles it globally:

```typescript
// src/lib/axios.ts
import axios from 'axios';
import { queryClient } from './queryClient';

axios.interceptors.response.use((response) => {
    const fullBust = response.headers['x-cache-full-bust'];
    const bustedTags = response.headers['x-cache-tags-busted'];

    if (fullBust === 'true') {
        // Bulk operation busted too many tags to enumerate — invalidate everything
        queryClient.invalidateQueries();
        return response;
    }

    if (bustedTags) {
        bustedTags.split(',').forEach((tag) => {
            queryClient.invalidateQueries({ queryKey: [tag] });
        });
    }

    return response;
});
```

Your query keys should match the tag format the server emits. For a tenant-scoped repository, tags are `tenant:{id}:{table}:listing` and `tenant:{id}:{table}:record:{id}`:

```typescript
// Products list query — key matches the listing tag
useQuery({
    queryKey: [`tenant:${tenantId}:products:listing`],
    queryFn: () => api.get('/products'),
});

// Single product query — key matches the record tag
useQuery({
    queryKey: [`tenant:${tenantId}:products:record:${productId}`],
    queryFn: () => api.get(`/products/${productId}`),
});
```

When a product is updated, the server emits both the record and listing tags. The interceptor invalidates both queries automatically — the list re-fetches to reflect any ordering change, and the detail page re-fetches to show the new data.

#### Without a Framework (Vanilla Fetch)

```typescript
async function apiMutation(url: string, data: unknown) {
    const response = await fetch(url, {
        method: 'POST',
        body: JSON.stringify(data),
    });

    const fullBust = response.headers.get('x-cache-full-bust');
    const bustedTags = response.headers.get('x-cache-tags-busted');

    if (fullBust === 'true') {
        cache.clear();  // your cache abstraction
    } else if (bustedTags) {
        bustedTags.split(',').forEach(tag => cache.invalidate(tag));
    }

    return response.json();
}
```

### What Tags Are Emitted Per Operation

| Repository operation | Tags emitted                                                                        |
| -------------------- | ----------------------------------------------------------------------------------- |
| `create`             | `{scope}:{table}:listing`, `{scope}:{table}:related`                                |
| `update`             | `{scope}:{table}:record:{id}`, `{scope}:{table}:listing`, `{scope}:{table}:related` |
| `delete`             | `{scope}:{table}:record:{id}`, `{scope}:{table}:listing`, `{scope}:{table}:related` |

The `:related` tag covers queries that eager-loaded this model as a relation. For example, a `POST` query cached with `->with('comments')` is tagged with the `comments` related tag — updating a comment invalidates it automatically.

### Octane Safety

`CacheBustCollector` is registered as `scoped()` — a fresh instance per request in Octane. There is no state leak between concurrent requests. The `scoped()` binding also ensures that `RepositoryCache` (which records tags) and `AttachCacheHeaders` (which reads them) resolve the exact same instance within one request lifecycle.
