Repository Pattern wytworzył prawdopodobnie więcej laravelowego boilerplate'u niż użytecznej izolacji. UserRepositoryInterface z metodami find, all, create i update wygląda architektonicznie. Najczęściej jest po prostu drugim, mniej wygodnym zapisem Eloquentu. Kontroler wciąż wie, że potrzebuje użytkowników, aplikacja wciąż przechowuje ich w MySQL, a każde zapytanie wymaga teraz metody w dwóch miejscach.
Nie jest to argument przeciw repozytoriom. To argument za używaniem ich wyłącznie na prawdziwej granicy. Eloquent to doskonała implementacja Active Record: obsługuje persistence, relacje, casty, paginację, factories i składanie zapytań. Zastąpienie go ogólnym CRUD nie uniezależnia aplikacji od trwałości danych. Sprawia jedynie, że jej API jest trudniejsze w użyciu.
Ten artykuł najpierw buduje regułę decyzyjną, a następnie granicę, która zasługuje na koszt utrzymania: wyszukiwanie kursu walutowego obsługiwane przez dostawcę, cache i lokalną alternatywę. Ten sam kształt pasuje do katalogów, modeli odczytu składanych z kilku systemów oraz SDK dostawców, których kod domeny nie powinien znać.
Zacznij od zapytania, na które Eloquent już odpowiada
Zwykłe zapytanie aplikacyjne nie potrzebuje repozytorium tylko dlatego, że występuje poza modelem. Trzymaj je blisko use case'u i nazwij intencję. Ten ekran faktur potrzebuje dziesięciu ostatnich zaległych faktur dla konta:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Invoice;
use Inertia\Inertia;
use Inertia\Response;
final class AccountInvoicesController
{
public function __invoke(Account $account): Response
{
$invoices = Invoice::query()
->whereBelongsTo($account)
->where('status', 'overdue')
->latest('due_date')
->limit(10)
->get(['id', 'number', 'due_date', 'total_cents']);
return Inertia::render('accounts/invoices', [
'account' => $account->only(['id', 'name']),
'invoices' => $invoices,
]);
}
}
Nie ma tu wycieku, który trzeba naprawić. Use case jest wprost zapytaniem bazodanowym po Invoice, a Eloquent należy do modelu tej aplikacji. Lokalny scope poprawia ponowne użycie bez wymyślania kolejnej warstwy dostępu do danych:
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
final class Invoice extends Model
{
public function scopeOverdueFor(Builder $query, Account $account): void
{
$query
->whereBelongsTo($account)
->where('status', 'overdue')
->orderByDesc('due_date');
}
}
Kontroler może teraz użyć Invoice::query()->overdueFor($account). To wystarcza, gdy konsument świadomie korzysta z Eloquentu. Nie dodawaj InvoiceRepository::overdueFor() wyłącznie po to, aby dało się stworzyć mock. Laravel factories i testowa baza mogą sprawdzić prawdziwe zapytanie; zwykle jest to bardziej wartościowe niż udowodnienie, że mock otrzymał nazwę metody.
Zapach repozytorium: powtarzanie Eloquentu
Ten znajomy interfejs niemal nie ma niezależnego znaczenia:
interface UserRepositoryInterface
{
public function find(int $id): ?User;
public function all(): Collection;
public function create(array $attributes): User;
public function update(User $user, array $attributes): User;
}
Tworzy pytania, na które Eloquent już odpowiedział. Czy all() paginuje? Czy wywołujący może eager-loadować relacje? Co z użytkownikami soft-deleted, transakcjami, lockami, chunkingiem, firstOrCreate albo nowym warunkiem? Albo interfejs rozrośnie się tak, że zacznie odwzorowywać Builder, albo wywołujący zażądają furtki. Oba wyniki pokazują, że granica jest fałszywa.
Najbardziej szkodliwa furtka zwraca builder:
public function query(): Builder;
Gdy wywołujący dopisują where, with i paginate, znają API Eloquentu, semantykę bazy i słownictwo tabel. Zmiana implementacji złamie wszystkich konsumentów. Builder jest w porządku w celowo eloqentowym serwisie zapytań, ale nie jest abstrakcją nad persistence. Nazwij taki serwis OverdueInvoiceQuery i zachowaj jasność jego celu.
Prawdziwa granica: kursy walut
Akcja wyceny zamówienia chce kursu z EUR na PLN; nie powinna wiedzieć, czy pochodzi z płatnego API, lokalnej tabeli czy zachowanego snapshotu. Jej słownictwo aplikacyjne jest wąskie i stabilne:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
interface ExchangeRateProvider
{
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate;
}
Value object trzyma JSON dostawcy oraz decyzje o liczbach zmiennoprzecinkowych poza resztą aplikacji:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
final readonly class ExchangeRate
{
public function __construct(
public string $base,
public string $quote,
public string $rate,
public DateTimeImmutable $publishedAt,
) {
}
public function convertCents(int $amountCents): int
{
return (int) round($amountCents * (float) $this->rate);
}
}
Kontrakt nie ma find, save, modelu, buildera ani ogólnej array. Obiecuje operację biznesową potrzebną wywołującemu. Implementacja dostawcy mapuje HTTP w jednym punkcie:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Rates;
use App\Billing\ExchangeRate;
use App\Billing\ExchangeRateProvider;
use DateTimeImmutable;
use Illuminate\Http\Client\Factory as Http;
use RuntimeException;
final readonly class FrankfurterExchangeRateProvider implements ExchangeRateProvider
{
public function __construct(private Http $http)
{
}
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate
{
$response = $this->http->baseUrl('https://api.frankfurter.dev/v1')
->get($at->format('Y-m-d'), ['base' => $base, 'symbols' => $quote])
->throw();
$rate = $response->json("rates.{$quote}");
if (! is_numeric($rate)) {
throw new RuntimeException("No {$base}/{$quote} rate is available.");
}
return new ExchangeRate(
base: $base,
quote: $quote,
rate: (string) $rate,
publishedAt: new DateTimeImmutable($response->json('date')),
);
}
}
SDK albo klient HTTP jest teraz infrastrukturą. Jeśli dostawca zmieni schemat odpowiedzi, zmienia się jeden adapter. Jeśli compliance wymusi innego dostawcę, wycena zamówienia pozostaje bez zmian.
Komponuj zachowanie wokół granicy
Cache powinien otaczać ten kontrakt, a nie siedzieć w kontrolerze ani być kopiowany u każdego dostawcy. Dekorator dodaje go bez rozszerzania interfejsu:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
use Illuminate\Contracts\Cache\Repository as Cache;
final readonly class CachedExchangeRateProvider implements ExchangeRateProvider
{
public function __construct(
private ExchangeRateProvider $provider,
private Cache $cache,
) {
}
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate
{
$key = sprintf('exchange-rate:%s:%s:%s', $base, $quote, $at->format('Y-m-d'));
return $this->cache->remember($key, now()->addHour(), fn (): ExchangeRate =>
$this->provider->rateFor($base, $quote, $at),
);
}
}
Kontrakt aplikacji zwiąż w jednym providerze. To jedyne miejsce wybierające infrastrukturę:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Billing\CachedExchangeRateProvider;
use App\Billing\ExchangeRateProvider;
use App\Infrastructure\Rates\FrankfurterExchangeRateProvider;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Http\Client\Factory as Http;
use Illuminate\Support\ServiceProvider;
final class BillingServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ExchangeRateProvider::class, function ($app): ExchangeRateProvider {
return new CachedExchangeRateProvider(
provider: new FrankfurterExchangeRateProvider($app->make(Http::class)),
cache: $app->make(Cache::class),
);
});
}
}
Use case zależy tylko od kontraktu i zachowuje widoczną politykę zaokrąglania:
<?php
declare(strict_types=1);
namespace App\Billing;
use DateTimeImmutable;
final readonly class ConvertOrderTotal
{
public function __construct(private ExchangeRateProvider $rates)
{
}
public function handle(int $totalCents, string $from, string $to, DateTimeImmutable $at): int
{
return $this->rates->rateFor($from, $to, $at)->convertCents($totalCents);
}
}
Testuj kontrakt na właściwym poziomie
Use case szybko testuje się małym fake'em. Nie jest to mock HTTP, cache'a ani Eloquentu; implementuje kontrakt aplikacji:
<?php
declare(strict_types=1);
use App\Billing\ConvertOrderTotal;
use App\Billing\ExchangeRate;
use App\Billing\ExchangeRateProvider;
use DateTimeImmutable;
it('converts an order total using the selected rate', function (): void {
$rates = new class implements ExchangeRateProvider {
public function rateFor(string $base, string $quote, DateTimeImmutable $at): ExchangeRate
{
return new ExchangeRate($base, $quote, '4.5000', $at);
}
};
$total = (new ConvertOrderTotal($rates))->handle(
totalCents: 10_00,
from: 'EUR',
to: 'PLN',
at: new DateTimeImmutable('2026-01-15'),
);
expect($total)->toBe(45_00);
});
Adapter dostawcy testuj na jego granicy przez Http::fake(), a dekorator cache'a przez magazyn cache Laravel. Te testy chronią mapowanie i zasady wygaśnięcia. Nie udają, że ogólne repozytorium usunęło zewnętrzne dane.
Gdzie repozytorium jest warte kosztu
Repozytorium albo provider zwraca się, gdy daje aplikacji jedno spójne źródło możliwości:
- Model odczytu łączy zamówienia MySQL, indeks wyszukiwania i API uprawnień w
CustomerDashboard. Zwróć celowy DTO, a nie trzy buildery. - Gateway katalogu ukrywa SDK dostawcy, którego obiekty, tokeny paginacji i wyjątki nie powinny trafić do kodu checkoutu.
- Port pozwala pakietowi lub warstwie domenowej działać bez Eloquentu. Stosuj value objecty domeny oraz konkretne operacje, np.
reserve(Sku $sku, Quantity $quantity). - Cacheowalne wyszukiwanie danych referencyjnych centralizuje klucze, invalidację i fallback. Wywołujący pytają o regułę podatkową albo kurs, nie o klucz cache'a.
Istnieje wiarygodna druga implementacja albo zmienny szczegół, którego zmiana inaczej dotknęłaby wielu use case'ów. To jest test, a nie liczba wywołań modelu w kontrolerze.
Pułapki, alternatywy i kiedy nie używać
Nie rób z repozytorium worka bez dna. ProductRepository, które pobiera produkty, zapisuje produkty, liczy stan, dzwoni do dostawcy i unieważnia cache, ma kilka powodów do zmiany. Rozdziel możliwości na InventoryGateway, ProductCatalogue i akcję będącą właścicielem workflow.
Nie używaj repozytorium, aby ukryć właściciela transakcji. Serwis wywołujący save() trzy razy potrzebuje jawnej transakcji na granicy use case'u; ukrycie każdego zapisu za metodą repozytorium nie czyni workflow atomowym. Nie przeciągaj też każdej relacji Eloquent przez repozytorium: ukrycie wymagania eager-loadingu często kończy się zapytaniami N+1.
Wybierz prostszą alternatywę, kiedy pasuje: Eloquent scope dla wielokrotnie używanego warunku modelu; query object dla złożonego zapytania, które powinno zachować semantykę Eloquentu; akcję/serwis dla orkiestracji, walidacji, transakcji i efektów ubocznych; albo wrapper klienta HTTP/adapter SDK dla jednego zewnętrznego dostawcy.
Nie dodawaj repozytorium dlatego, że wymaga go „clean architecture”, bo testy jednostkowe muszą mockować bazę albo dlatego, że każdy model rzekomo potrzebuje interfejsu. Takie zasady tworzą abstrakcję przed dowodem. Zacznij od bezpośredniego Eloquentu, wypatruj powtarzającej się wiedzy o infrastrukturze i wprowadź wąski kontrakt dopiero, gdy pojawi się konkretna granica.
Uczciwy test decyzyjny
Przed dodaniem interfejsu zapytaj: czy wywołujący potrzebuje możliwości biznesowej, a nie operacji bazodanowych? Czy druga implementacja jest wiarygodna? Czy zmiana magazynu lub dostawcy inaczej dotknęłaby wielu use case'ów? Czy kontrakt może uniknąć modeli Eloquent, builderów, DTO dostawcy oraz ogólnych tablic?
Jeśli większość odpowiedzi brzmi „tak”, port w kształcie repozytorium się zwróci. Jeśli nie, napisz czytelne zapytanie Eloquent. Celem nie jest abstrakcja każdej zależności. Chodzi o to, aby zależności naprawdę podatne na zmianę były nudne w zmianie.