← Back to Dashboard

Repository Pattern

Laravel: Illuminate\Cache\Repository, Auth\UserProvider, Eloquent as implicit repository

The Repository Pattern

Note: The Repository pattern is not one of the original Gang of Four (GoF) design patterns. It originates from Martin Fowler's Patterns of Enterprise Application Architecture and is a standard practice in Laravel and modern PHP.

The Real-World Analogy

Think of a bank's transaction ledger. In the old days, a teller would physically walk to the vault room, open a specific binder, flip to the right page, and write or read a transaction record. The teller knew exactly where the binder was, what format the pages used, and how the filing system worked.

Now imagine a modern bank. The teller interacts with a terminal screen. They type in a transaction ID and get the record back. They don't know -- or care -- whether that record lives in a PostgreSQL database, a mainframe, a microfilm archive, or a distributed ledger. The terminal provides a uniform interface: "give me this record" and "save this record." If the bank migrates from one storage system to another, the tellers don't need retraining.

That terminal screen is a Repository. It provides a clean abstraction over data storage so that the rest of your application never couples to a specific storage mechanism.

The Anti-Pattern: Direct Storage Access in Business Logic

Here's what happens when your service talks directly to the database (or in this case, directly manipulates a storage mechanism):

class TransactionService
{
    public function createTransaction(float $amount, string $description): array
    {
        // Direct database access — tightly coupled to Eloquent
        $transaction = new Transaction();
        $transaction->id = 'TXN-' . strtoupper(substr(md5(uniqid()), 0, 8));
        $transaction->amount = $amount;
        $transaction->description = $description;
        $transaction->created_at = date('Y-m-d H:i:s');
        $transaction->save(); // Eloquent-specific

        return $transaction->toArray();
    }

    public function findTransaction(string $id): ?array
    {
        // Direct Eloquent query — can't test without a database
        $transaction = Transaction::where('id', $id)->first();
        return $transaction?->toArray();
    }

    public function getRecentTransactions(int $limit = 10): array
    {
        // More direct Eloquent usage
        return Transaction::orderBy('created_at', 'desc')
            ->limit($limit)
            ->get()
            ->toArray();
    }
}

Why this is a problem:

  1. Untestable without a database. Every test requires a real database connection, migrations, seeding, and teardown. Tests are slow and fragile.
  2. Locked to Eloquent. If you need to switch to an API-backed storage, a file-based system, or even a different ORM, you must rewrite the entire service.
  3. Business logic contaminated with storage details. The service knows about ->save(), ::where(), ->orderBy() -- these are Eloquent's API, not your domain's language.
  4. Hard to use in different contexts. Want to run the same logic against cached data? Against test fixtures? You can't -- it always hits the database.

The Fix: Repository Interface + Implementation

Define a contract for data access, then provide concrete implementations:

interface TransactionRepositoryInterface
{
    public function save(array $data): array;
    public function findById(string $id): ?array;
    public function all(): array;
}

// Production implementation using Eloquent
class EloquentTransactionRepository implements TransactionRepositoryInterface
{
    public function save(array $data): array
    {
        $transaction = Transaction::create($data);
        return $transaction->toArray();
    }

    public function findById(string $id): ?array
    {
        $transaction = Transaction::find($id);
        return $transaction?->toArray();
    }

    public function all(): array
    {
        return Transaction::all()->toArray();
    }
}

// Test implementation — no database required
class InMemoryTransactionRepository implements TransactionRepositoryInterface
{
    private array $transactions = [];

    public function save(array $data): array
    {
        $this->transactions[$data['id']] = $data;
        return $data;
    }

    public function findById(string $id): ?array
    {
        return $this->transactions[$id] ?? null;
    }

    public function all(): array
    {
        return array_values($this->transactions);
    }
}

Now your service depends on the interface, not the implementation:

class TransactionService
{
    public function __construct(
        private TransactionRepositoryInterface $repository
    ) {}

    public function createTransaction(float $amount, string $description): array
    {
        $data = [
            'id' => 'TXN-' . strtoupper(substr(md5(uniqid()), 0, 8)),
            'amount' => $amount,
            'description' => $description,
            'created_at' => date('Y-m-d H:i:s'),
        ];
        return $this->repository->save($data);
    }

    public function findTransaction(string $id): ?array
    {
        return $this->repository->findById($id);
    }
}

In production, you bind the Eloquent implementation. In tests, you pass the in-memory version. The service code is identical in both cases.

The In-Memory Implementation: Your Testing Superpower

The in-memory repository is not a hack or a shortcut. It's a first-class implementation that happens to store data in a PHP array instead of a database. It's fast, deterministic, and requires zero infrastructure:

// In a test
$repo = new InMemoryTransactionRepository();
$service = new TransactionService($repo);

$created = $service->createTransaction(500.00, 'Test payment');
$found = $service->findTransaction($created['id']);

assert($found !== null);
assert($found['amount'] === 500.00);

This test runs in microseconds, needs no database, and will never fail because of a flaky connection or leftover test data.

Repository vs. Active Record

Laravel uses the Active Record pattern through Eloquent, where the model itself handles data access ($user->save(), User::find(1)). This works well for simple CRUD, but as your application grows, you often need the indirection that a repository provides.

The Repository pattern doesn't replace Eloquent -- it wraps it. Your Eloquent implementation uses Eloquent models internally, but exposes a clean interface to the rest of your application.

Use Active Record directly when:

  • Simple CRUD with no complex business logic
  • Rapid prototyping where speed matters more than architecture

Use Repository when:

  • Business logic is complex and needs to be tested without a database
  • You might switch storage backends (database to API, SQL to NoSQL)
  • Multiple services need to access the same data with consistent rules
  • You want to enforce that data access goes through controlled methods

What a Repository Is NOT

A repository is not:

  • A query builder wrapper -- if your repository methods just mirror Eloquent methods (findWhere, findByColumn, paginateBy), you've created a leaky abstraction. Keep repository methods domain-focused: findPendingTransactions(), not findByStatus('pending').
  • A place for business logic -- the repository stores and retrieves data. Business rules live in services.
  • Required for every model -- a Setting or Country model that's read-only and rarely changes doesn't need a repository.

Key takeaway: The Repository pattern puts an interface between your business logic and your data storage. Your services speak the language of your domain ("save a transaction," "find by ID"), and the repository translates that into whatever storage mechanism is behind it. Swap the implementation; the business logic never knows.

What You Learned

  • The Repository pattern abstracts data access behind an interface, decoupling business logic from storage details
  • Coding to an interface (TransactionRepositoryInterface) means you can swap implementations (MySQL, in-memory, API) without changing consumers
  • In-memory repositories are invaluable for testing — they behave identically but need no database
  • The pattern enforces a clean boundary between "how data is stored" and "how data is used"

The Repository Pattern in Laravel

Laravel doesn't ship with an explicit Repository base class, but the pattern appears throughout the framework. And when you build your own repositories, Laravel's service container makes the wiring effortless.

Laravel's Built-in Repository-Like Patterns

Cache Repository

Illuminate\Cache\Repository is the clearest example. It provides a uniform interface for cache operations regardless of the underlying driver:

// Works identically whether using Redis, Memcached, file, or array driver
Cache::put('transaction:TXN-123', $data, 3600);
$data = Cache::get('transaction:TXN-123');
Cache::forget('transaction:TXN-123');

Under the hood, CacheManager resolves the appropriate Store implementation (RedisStore, FileStore, ArrayStore, etc.), and Repository wraps it with a clean, consistent API. Your application code never touches Redis commands or file operations directly.

Auth UserProvider

Laravel's authentication system uses Illuminate\Contracts\Auth\UserProvider as a repository interface for user retrieval:

interface UserProvider
{
    public function retrieveById($identifier);
    public function retrieveByToken($identifier, $token);
    public function updateRememberToken(Authenticatable $user, $token);
    public function retrieveByCredentials(array $credentials);
    public function validateCredentials(Authenticatable $user, array $credentials);
}

Laravel ships with EloquentUserProvider and DatabaseUserProvider. If you store users in an LDAP server or an external API, you implement this interface and register your custom provider -- the rest of the auth system works unchanged.

Filesystem

Illuminate\Filesystem\FilesystemAdapter wraps Flysystem to provide a uniform file storage API:

// Same code works for local disk, S3, GCS, or FTP
Storage::disk('s3')->put('receipts/TXN-123.pdf', $pdfContent);
$exists = Storage::disk('local')->exists('receipts/TXN-123.pdf');

The storage "disk" is effectively a repository for files.

Building Your Own Repository in Laravel

Step 1: Define the Interface

// app/Contracts/TransactionRepositoryInterface.php

namespace App\Contracts;

interface TransactionRepositoryInterface
{
    public function save(array $data): array;
    public function findById(string $id): ?array;
    public function findByStatus(string $status): array;
    public function all(): array;
}

Step 2: Create the Eloquent Implementation

// app/Repositories/EloquentTransactionRepository.php

namespace App\Repositories;

use App\Contracts\TransactionRepositoryInterface;
use App\Models\Transaction;

class EloquentTransactionRepository implements TransactionRepositoryInterface
{
    public function save(array $data): array
    {
        $transaction = Transaction::updateOrCreate(
            ['id' => $data['id'] ?? null],
            $data
        );
        return $transaction->toArray();
    }

    public function findById(string $id): ?array
    {
        $transaction = Transaction::find($id);
        return $transaction?->toArray();
    }

    public function findByStatus(string $status): array
    {
        return Transaction::where('status', $status)
            ->orderBy('created_at', 'desc')
            ->get()
            ->toArray();
    }

    public function all(): array
    {
        return Transaction::all()->toArray();
    }
}

Step 3: Create the In-Memory Implementation (for testing)

// app/Repositories/InMemoryTransactionRepository.php

namespace App\Repositories;

use App\Contracts\TransactionRepositoryInterface;

class InMemoryTransactionRepository implements TransactionRepositoryInterface
{
    private array $store = [];

    public function save(array $data): array
    {
        $id = $data['id'] ?? uniqid('txn_');
        $data['id'] = $id;
        $this->store[$id] = $data;
        return $data;
    }

    public function findById(string $id): ?array
    {
        return $this->store[$id] ?? null;
    }

    public function findByStatus(string $status): array
    {
        return array_values(array_filter(
            $this->store,
            fn(array $txn) => ($txn['status'] ?? '') === $status
        ));
    }

    public function all(): array
    {
        return array_values($this->store);
    }
}

Step 4: Bind in a Service Provider

// app/Providers/RepositoryServiceProvider.php

namespace App\Providers;

use App\Contracts\TransactionRepositoryInterface;
use App\Repositories\EloquentTransactionRepository;
use Illuminate\Support\ServiceProvider;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            TransactionRepositoryInterface::class,
            EloquentTransactionRepository::class
        );
    }
}

Now any class that type-hints TransactionRepositoryInterface automatically receives the Eloquent implementation.

Step 5: Use in a Service

// app/Services/TransactionService.php

namespace App\Services;

use App\Contracts\TransactionRepositoryInterface;

class TransactionService
{
    public function __construct(
        private TransactionRepositoryInterface $transactions
    ) {}

    public function recordPayment(float $amount, string $description): array
    {
        return $this->transactions->save([
            'id' => 'TXN-' . strtoupper(substr(md5(uniqid()), 0, 8)),
            'amount' => $amount,
            'description' => $description,
            'status' => 'completed',
            'created_at' => now()->toDateTimeString(),
        ]);
    }
}

Step 6: Swap for Testing

// tests/Unit/TransactionServiceTest.php

class TransactionServiceTest extends TestCase
{
    public function test_record_payment_saves_transaction(): void
    {
        // Use in-memory repo -- no database needed
        $repo = new InMemoryTransactionRepository();
        $service = new TransactionService($repo);

        $result = $service->recordPayment(500.00, 'Utility bill');

        $this->assertEquals(500.00, $result['amount']);
        $this->assertEquals('completed', $result['status']);

        // Verify it was actually stored
        $found = $repo->findById($result['id']);
        $this->assertNotNull($found);
    }
}

Domain-Focused Methods

Avoid making your repository a generic query builder. Instead of this:

// Too generic -- this is just wrapping Eloquent for no benefit
interface TransactionRepositoryInterface
{
    public function findWhere(string $column, mixed $value): array;
    public function findWhereIn(string $column, array $values): array;
    public function paginate(int $perPage): LengthAwarePaginator;
}

Prefer domain-specific methods:

// Domain-focused -- each method represents a real business need
interface TransactionRepositoryInterface
{
    public function save(array $data): array;
    public function findById(string $id): ?array;
    public function findPendingTransactions(): array;
    public function findByDateRange(string $from, string $to): array;
    public function sumByStatus(string $status): float;
}

Key takeaway: Laravel's service container makes the repository pattern seamless. Define an interface, create implementations (Eloquent for production, in-memory for tests), bind in a service provider, and type-hint everywhere. Your business logic stays clean and your tests stay fast.

This example is pre-filled and runnable. Click "Run" to see the output. Feel free to modify the code and experiment.

Output

                

            

Complete the code below to pass all test cases. Use the hints if you get stuck.