> 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.

## 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.
