> 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/repositories.md).

# Repositories

Core Foundation provides a feature-complete `BaseRepository` that serves as the foundation for your data access layer. It automates common CRUD tasks while allowing for deep customization through contracts and extension points.

## Contract-Based Implementation

To support **CQRS-lite** patterns, the repository system is split into multiple contracts. You should implement only the interfaces your repository actually needs.

| Contract                  | Operations Provided                           |
| ------------------------- | --------------------------------------------- |
| `ReadRepositoryContract`  | `fetchAll()`, `fetchById()`                   |
| `WriteRepositoryContract` | `create()`, `update()`, `delete()`            |
| `QueryRepositoryContract` | `query()` (entry point for custom builders)   |
| `RepositoryContract`      | **All of the above** (The most common choice) |

### Defining a Concrete Repository

Always extend `BaseRepository` and return the model class in `setModel()`:

```php
namespace App\Repositories;

use App\Models\Order;
use CoreFoundation\Repositories\BaseRepository;
use CoreFoundation\Repositories\Contracts\RepositoryContract;

class OrderRepository extends BaseRepository implements RepositoryContract
{
    protected function setModel(): string
    {
        return Order::class;
    }
}
```

## Advanced Querying

### Custom Queries

Never call `Order::query()` directly. Always use `$this->query()` inside your repository to ensure your queries start with a fresh builder scoped to the repository's model.

```php
public function fetchPendingOlderThan(int $days): Collection
{
    return $this->query()
        ->where('status', 'pending')
        ->where('created_at', '<', now()->subDays($days))
        ->get();
}
```

### Whitelisting Search & Sort

For security, the repository enforces a whitelist for filtering and sorting. Any request-driven filter for a column not in this list is silently ignored.

```php
protected function searchable(): array
{
    // Combine base model searchable columns with repository-specific ones
    return array_merge(parent::searchable(), ['internal_reference']);
}

protected function sortable(): array
{
    // Defaults to searchable(). Override to restrict further.
    return ['created_at', 'total'];
}
```

### Whitelisting Local Scopes

`fetchAll()` can also apply Eloquent local scopes — the `scopeXxx()` methods you'd normally chain manually (`Model::active()->recent()->get()`) — driven by `criteria['scopes']`. Like filters and sort, this is whitelist-gated: only scope names returned by `scopeable()` can be invoked.

```php
// On the model
public function scopeActive(Builder $query): Builder
{
    return $query->where('status', 'active');
}

public function scopeOfType(Builder $query, string $type): Builder
{
    return $query->where('type', $type);
}

// On the repository
protected function scopeable(): array
{
    return ['active', 'ofType'];
}
```

```php
// Name only — Model::scopeActive()
$repository->fetchAll(['scopes' => ['active']]);

// Name => arguments — Model::scopeOfType('digital')
$repository->fetchAll(['scopes' => ['ofType' => ['digital']]]);
```

A scope name not present in `scopeable()` is silently skipped — same rule as an unknown filter column or sort key. Unlike `searchable()`/`sortable()`, there is no model-derived default: every scope is an explicit opt-in, since a local scope can run arbitrary query logic, not just compare one column. This is also distinct from `Model::addScope()` (`ModelScopeable`), which registers always-on global scopes — `scopeable()` is for named, per-query, opt-in local scopes.

### Extending the Whitelist From Another Module

`scopeable()` is one repository's own declaration. In a modular monolith, a *different* module sometimes needs to extend it — for example, a Promotions module wants the Catalog module's `ProductRepository` to allow an `onSale` scope, without editing Catalog's source. This is the same [Modular Extensibility](/architecture-and-concepts/modular-extensibility.md) pattern models use for `fillable`/`casts`/`searchable`, applied to a repository's scope whitelist.

Register it from the Promotions module's own `ServiceProvider::boot()`:

```php
ProductRepository::addScopeable(['onSale', 'lowStock']);
```

Or fluently, via `BaseExtensionServiceProvider`:

```php
class PromotionsServiceProvider extends BaseExtensionServiceProvider
{
    protected function extendRepositories(): void
    {
        $this->repository(ProductRepository::class)
            ->scopeable(['onSale', 'lowStock']);
    }
}
```

|                    |                                                                                                                                                                                                                                |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Merge behavior** | Additions merge with `ProductRepository::scopeable()`'s own list — neither replaces the other.                                                                                                                                 |
| **Isolation**      | Keyed by `static::class` — registering against `ProductRepository` never leaks into a sibling repository's whitelist.                                                                                                          |
| **Best practice**  | Reach for `addScopeable()` whenever the module declaring the scope is not the module that owns the repository. Editing another module's `scopeable()` override directly is the dependency CoreFoundation is designed to avoid. |

### Fluent Scopes — Applying a Scope From a Service

`criteria['scopes']` exists for request-driven input — a `?scopes[]=active` query string — which is why it goes through the `scopeable()` whitelist. A service calling its own repository is not request input, so the whitelist is unnecessary friction. Call `scope()` directly instead — chainable, exactly like `lockForUpdate()`/`sharedLock()`, and applied to the **next** `fetchAll()` or `fetchById()` call only:

```php
// Single scope
$this->userRepository->scope('active')->fetchById($id);

// Scope with arguments — Model::scopeOfType('admin')
$this->userRepository->scope('ofType', ['admin'])->fetchAll();

// Chain multiple scopes
$this->userRepository
    ->scope('active')
    ->scope('verified')
    ->fetchAll();
```

|                           |                                                                                                                                                                                                                                                                   |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Not whitelist-gated**   | `scope()` calls Eloquent's `Builder::scopes()` directly. An unknown scope name throws `BadMethodCallException` immediately — the same as calling the scope on the model would.                                                                                    |
| **No persistent variant** | There is no "sticky" version that applies to every future call. A scope that should apply to *every* query already has a home: `Model::addScope()` (`ModelScopeable`), an always-on global scope.                                                                 |
| **Cache safety**          | The pending scope is read and reset **before** the cache layer decides hit or miss, never inside the query callback — a scoped call always lands in its own cache entry, and the very next unscoped call is guaranteed to still see the original unscoped result. |

**When to use which:** `criteria['scopes']` for anything shaped by the HTTP request; `scope()` for anything a service decides on its own.

### Fluent Eager Loading

Pass relations explicitly when the caller already knows them — `fetchById($id, relations: ['comments'])`. When a relation is only needed conditionally inside a service method, chain `with()` instead — same pending-state pattern as `scope()`, applied to the **next** `fetchAll()` or `fetchById()` call only:

```php
// Single relation
$this->postRepository->with('comments')->fetchById($id);

// Multiple relations
$this->postRepository->with(['comments', 'author'])->fetchAll();

// Merges with an explicit relations argument — never replaces it
$this->postRepository->with('comments')->fetchById($id, relations: ['author']);
```

|                           |                                                                                                                                                                                                                                                                                               |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Merge, not replace**    | `with()` adds to whatever `$relations` the call already passes — duplicates are deduplicated, neither side is dropped.                                                                                                                                                                        |
| **No persistent variant** | Same rule as `scope()` — a relation needed on every call belongs in the repository's default `$relations`, not in a sticky fluent flag.                                                                                                                                                       |
| **Cache safety**          | The pending relations are folded into the existing `$relations` parameter **before** the cache layer decides hit or miss — it already participates in both the cache key and relation-aware invalidation tags, so an eager-loaded call and a bare call never collide on the same cache entry. |

## Cache Invalidation

Every write method busts its own cache **explicitly and inline** — `create()` calls `flushAll()`, `update()`/`updateAtomic()`/`delete()` call `flushRecord()`, right at the call site, in the same method that performed the write. No event, no listener, no indirection — read the method top-to-bottom and you see exactly what gets invalidated and why.

| Repository method                          | Cache action                                                 |
| ------------------------------------------ | ------------------------------------------------------------ |
| `create()` / `sync()`                      | `flushAll()` — busts every tier                              |
| `update()` / `updateAtomic()` / `delete()` | `flushRecord()` — busts the record tier and the listing tier |

Updating a single record invalidates only that record and its listing — all other records stay warm. See [Caching Strategy](/database-and-repositories/caching.md) for the full two-tier design.

**Mutations that bypass the repository entirely** — a queued job or console command calling `Order::create()` directly, for example — never reach the code above. For that case, opt a model into `RepositoryCacheObserver` (an Eloquent observer, not a repository hook): see [Caching Strategy](/database-and-repositories/caching.md) for registration. It is a defensive safety net for non-repository-mediated writes, not the repository's own invalidation mechanism.

### `updateAtomic()` Bypasses Eloquent Model Events

`updateAtomic()` performs its update via a conditional query-builder statement (`->where($conditions)->update($attributes)`) rather than `$model->save()`, so the optimistic-lock check is a single atomic SQL statement. This means **Eloquent's `updating`/`updated` model events do not fire** for it — a mass update via the query builder never triggers instance-level events, by Eloquent's own design.

Cache invalidation still works correctly (it's the explicit `flushRecord()` call above, not an event listener), but any `BaseObserver` hook your model has registered — search-index sync, audit logging, anything wired to `updating`/`updated` — will silently **not** run for an atomic update. If a model's observer must see every update including atomic ones, that side effect belongs in the service calling `updateAtomic()`, not in a model observer.

## Transactions

**Never open a transaction inside a repository.** A single `create()` or `update()` call is atomic by SQL's own rules. The moment you need a transaction, you are coordinating multiple writes — that is the service's job.

```php
// Wrong — transaction inside a repository
public function create(array $data): Order
{
    return DB::transaction(fn () => $this->query()->create($data));
}

// Correct — the service wraps the transaction and calls multiple repos inside it
// (See base-service.md → Database Transactions)
```

## Binding Strategy

**Always bind repositories as transient (`bind()`)** in your ServiceProviders. Never use `singleton()` or `scoped()`. Repositories carry mutable state (like applied filters or eager-loaded relations) that must be fresh for every resolution.

```php
$this->app->bind(OrderRepositoryInterface::class, OrderRepository::class);
```

**Why not singleton or scoped?**

* `singleton()` — a singleton repository captures any injected dependencies at first resolution. In Octane, the same instance is reused across requests, causing cross-request data leaks if the repository accumulates filter state.
* `scoped()` — safer than singleton but still risky if the repository accumulates filter state within a single request and is resolved multiple times.
