risetechapps / repository-for-laravel
Repository pattern para Laravel com cache transparente, soft deletes, views materializadas (PostgreSQL), eventos e métricas de query.
Package info
github.com/risetechapps/repository-for-laravel
pkg:composer/risetechapps/repository-for-laravel
Requires
- php: ^8.3
- illuminate/support: ^12
- predis/predis: ^v3.3.0
- risetechapps/has-uuid-for-laravel: ^1.2.0
- risetechapps/to-upper-for-laravel: ^1.1.0
- tpetry/laravel-postgresql-enhanced: ^3.5.0
Requires (Dev)
- fakerphp/faker: ^1.23
- laravel/pail: ^1.2.2
- laravel/pint: ^1.24
- laravel/sail: ^1.41
- mockery/mockery: ^1.6
- nunomaduro/collision: ^8.6
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.1
- pestphp/pest-plugin-laravel: ^4.0
README
📌 Sobre o Projeto
O Laravel Repository é um package para Laravel que abstrai a camada de dados, tornando a aplicação mais flexível e fácil de manter. Ele oferece cache automático com suporte a tags, soft deletes, materialized views (PostgreSQL), paginação dinâmica e um conjunto completo de métodos encadeáveis para consulta e manipulação de dados.
✨ Funcionalidades Principais
- 🗂️ Cache Inteligente - Cache automático com TTL configurável, tags e invalidação
- 🔄 Soft Deletes -
useTrashed(),onlyTrashed(),restore()eforceDelete() - 📊 Materialized Views - Suporte nativo a views materializadas do PostgreSQL
- 🔍 Buscas Avançadas - Filtros customizados, full-text, fuzzy search, JSONB
- 🎯 Scopes - Scopes reutilizáveis no repositório (
scope()), default scopes sempre-on ($defaultScopes) e opt-out por query (withoutScope()) - 📦 Operações em Lote -
storeMany,updateMany,deleteMany,upsert - ⚡ Performance - Cursor pagination, selects otimizados, cache warming
- 🔔 Eventos - Eventos para create/update/delete e limpeza de cache, silenciáveis por operação (
withoutEvents()) e à prova de loop - 🛡️ Segurança - Sanitização automática, validação de operadores e de colunas (SQL injection protection em buscas raw)
- 🔌 Conexão do model - Consultas, views e transações respeitam a conexão definida no model
- 🧩 Agnóstico de tenancy - Isolamento de views via hook
applyViewScope(), sem acoplar regra de multi-tenancy - 📈 Métricas - Query logging, cache hit rate, estatísticas de uso
📋 Novidades (v3.0.0)
⚠️ Breaking change: o isolamento automático de views por
SharingPolicyfoi removido do package. O package agora é agnóstico de tenancy — o isolamento de views passa a ser feito sobrescrevendo o hookapplyViewScope()(ver seção Isolamento nas Views).
Novos Métodos
findOrFail()- Busca pelo ID lançandoEntityNotFoundExceptionse não existirtransaction()- Operações atômicas na conexão do modelflushTags()- Invalidação granular de cache por tagresetMetrics()- Zera as métricas acumuladas (útil em workers long-running)firstOrCreate()/updateOrCreate()- Busca ou cria/atualizaduplicate()- Clona registros com modificaçõesincrement()/decrement()- Operações atômicaswhereDate()/whereIn()/whereBetween()/groupBy()- Filtros avançadosview()- Query Builder para Materialized ViewscacheFor()/cacheIf()/withCacheTags()- Cache avançadowhen()/selectOptimized()/cursorPaginate()- Performance
Melhorias
- Desacoplamento de tenancy: isolamento de views agora via hook
applyViewScope()(sem dependência deSharingPolicyno package) - Conexão do model respeitada em views materializadas, SQL raw e transações
- Segurança: validação de nome de coluna (whitelist + identificador) em
fuzzySearch/searchFullText/findWhereJson cacheIf()agora realmente condiciona o cache;withCacheTags()cria pontos de invalidação reaisregisterViews()passou a ter default ([]) — opcional em repositórios sem views- Modo
strictemrefreshMaterializedViews()(falha-rápido nos comandos artisan) warming_enabled/warming_methodsdo config agora são respeitados- Suíte de testes (Pest + Testbench) adicionada
- Eventos do Repository (
RepositoryCreated,RepositoryUpdated, etc.) - Serialização segura nos Jobs (
afterCommit) - Configuração expandida em
config/repository.php
🚀 Instalação
Requisitos
- PHP >= 8.3
- Laravel >= 12
- PostgreSQL (para uso de Materialized Views)
- Composer instalado
1. Instalar o package
composer require risetechapps/repository-for-laravel
2. Publicar as configurações
php artisan vendor:publish --provider="RiseTechApps\Repository\RepositoryServiceProvider"
3. Criar um Repository
php artisan repository:make {name}
4. Configurar o Repository e a Interface
// app/Repositories/ClientEloquentRepository.php /** * @extends BaseRepository<\App\Models\Client> */ class ClientEloquentRepository extends BaseRepository implements ClientRepository { public function entity(): string { return Client::class; } } // app/Repositories/Contracts/ClientRepository.php interface ClientRepository extends RepositoryInterface { // métodos customizados do domínio aqui }
O
@extends BaseRepository<\App\Models\Client>é opcional, mas habilita autocomplete e análise estática precisos:findById()retornaClient|null,get()retornaCollection<int, Client>, etc. Repositórios gerados porphp artisan repository:makejá incluem essa anotação.
5. Definir colunas permitidas para ordenação (segurança)
class ClientEloquentRepository extends BaseRepository implements ClientRepository { // Apenas estas colunas são aceitas como sort_column no paginate() // Se omitido, o fallback é sempre 'id' protected array $allowedSortColumns = [ 'id', 'nome', 'email', 'created_at', 'status', ]; public function entity(): string { return Client::class; } }
📖 Referência de Métodos
Leitura
get()
Retorna todos os registros do modelo.
$clients = $clientRepository->get();
first()
Retorna o primeiro registro encontrado.
$client = $clientRepository->first();
findById($id)
Busca um registro pelo ID. Retorna null se não existir.
$client = $clientRepository->findById(1);
findOrFail($id)
Variante estrita de findById(). Lança EntityNotFoundException (HTTP 404) quando o registro não existe — útil em rotas que esperam o recurso.
use RiseTechApps\Repository\Exception\EntityNotFoundException; try { $client = $clientRepository->findOrFail($id); } catch (EntityNotFoundException $e) { // $e->getEntityName(), $e->getSearchedId() }
findWhere(array $conditions)
Filtra registros por condições simples de igualdade.
$clients = $clientRepository->findWhere([ 'status' => 'ativo', 'plano_id' => 3, ]);
findWhereFirst($column, $value)
Retorna o primeiro registro que corresponda ao filtro.
$client = $clientRepository->findWhereFirst('email', 'joao@email.com');
findWhereEmail($email)
Atalho para buscar registros pelo campo email.
$clients = $clientRepository->findWhereEmail('joao@email.com');
findWhereCustom(array $conditions)
Filtros avançados com suporte a operadores, grupos OR/AND, BETWEEN, IN, LIKE, IS NULL, etc.
// Filtro simples com operador $clients = $clientRepository->findWhereCustom([ ['column' => 'status', 'operator' => '=', 'value' => 'ativo'], ['column' => 'created_at', 'operator' => '>=', 'value' => '2024-01-01'], ]); // BETWEEN $clientRepository->findWhereCustom([ ['column' => 'total', 'operator' => 'BETWEEN', 'value' => [100, 500]], ]); // IN $clientRepository->findWhereCustom([ ['column' => 'status', 'operator' => 'IN', 'value' => ['ativo', 'trial']], ]); // LIKE $clientRepository->findWhereCustom([ ['column' => 'nome', 'operator' => 'LIKE', 'value' => 'João'], ]); // IS NULL / IS NOT NULL $clientRepository->findWhereCustom([ ['column' => 'deleted_at', 'operator' => 'IS', 'value' => null], ]); // Grupo OR $clientRepository->findWhereCustom([ ['orGroup' => [ ['column' => 'status', 'operator' => '=', 'value' => 'ativo'], ['column' => 'status', 'operator' => '=', 'value' => 'trial'], ]], ]); // Grupo AND dentro de OR $clientRepository->findWhereCustom([ ['andGroup' => [ ['column' => 'plano_id', 'operator' => '=', 'value' => 2], ['column' => 'ativo', 'operator' => '=', 'value' => true], ]], ]);
whereDate($column, $operator, $value)
Filtra registros por data. Suporta operadores de comparação.
// Registros criados em 2024 $clients = $clientRepository->whereDate('created_at', '>=', '2024-01-01')->get(); // Pedidos de hoje $todayOrders = $orderRepository->whereDate('created_at', '=', now()->format('Y-m-d'))->get(); // Registros do mês passado $lastMonth = $clientRepository->whereDate('created_at', '>=', now()->subMonth())->get();
whereIn($column, array $values)
Filtra registros onde a coluna está nos valores informados.
// Status específicos $clients = $clientRepository->whereIn('status', ['ativo', 'pendente'])->get(); // IDs específicos $selected = $clientRepository->whereIn('id', [1, 2, 3, 4, 5])->get(); // Com encadeamento $recentActive = $clientRepository ->whereIn('status', ['ativo', 'premium']) ->whereDate('created_at', '>=', now()->subDays(30)) ->get();
whereBetween($column, array $values)
Filtra registros onde a coluna está entre dois valores.
// Faixa de valores $midRange = $orderRepository->whereBetween('valor', [100, 500])->get(); // Período de datas $inPeriod = $clientRepository->whereBetween('created_at', [ '2024-01-01', '2024-12-31' ])->get(); // Preço com desconto $discounted = $productRepository->whereBetween('discount_percentage', [10, 50])->get();
groupBy($columns)
Agrupa resultados por coluna(s). Útil para consultas agregadas.
// Agrupar por status $byStatus = $clientRepository->select(['status', DB::raw('COUNT(*) as total')]) ->groupBy('status') ->get(); // Agrupar por mês $byMonth = $orderRepository ->select([ DB::raw("DATE_TRUNC('month', created_at) as month"), DB::raw('SUM(valor) as total'), DB::raw('COUNT(*) as quantity') ]) ->groupBy(DB::raw("DATE_TRUNC('month', created_at)")) ->get();
count()
Retorna o total de registros no escopo atual, sem carregar dados.
$total = $clientRepository->count(); // Somente excluídos $totalExcluidos = $clientRepository->onlyTrashed()->count(); // Incluindo excluídos $totalGeral = $clientRepository->useTrashed(true)->count();
exists()
Verifica se existe ao menos um registro no escopo atual.
if ($clientRepository->exists()) { // há registros } // Verificar se há excluídos if ($clientRepository->onlyTrashed()->exists()) { // há registros deletados }
pluck(string $column, ?string $key = null)
Retorna apenas os valores de uma coluna, sem carregar models completos.
// Lista simples de nomes $nomes = $clientRepository->pluck('nome'); // => Collection ['João', 'Maria', 'Carlos'] // Mapeado por ID (útil para selects e autocompletes) $opcoes = $clientRepository->pluck('nome', 'id'); // => Collection [1 => 'João', 2 => 'Maria'] // Somente excluídos $clientRepository->onlyTrashed()->pluck('email');
sum(string $column)
Retorna a soma dos valores de uma coluna numérica.
$totalFaturado = $pedidoRepository->sum('total'); // Somente pedidos cancelados (excluídos) $totalCancelado = $pedidoRepository->onlyTrashed()->sum('total');
avg(string $column)
Retorna a média dos valores de uma coluna numérica.
$mediaNota = $avaliacaoRepository->avg('nota'); $mediaAtivos = $avaliacaoRepository->findWhere(['status' => 'publicado']); // use avg() diretamente para médias por escopo $mediaGeral = $avaliacaoRepository->avg('nota');
min(string $column)
Retorna o menor valor de uma coluna.
$menorPreco = $produtoRepository->min('preco'); $primeiroCadastro = $clientRepository->min('created_at');
max(string $column)
Retorna o maior valor de uma coluna.
$maiorPreco = $produtoRepository->max('preco'); $ultimoAcesso = $clientRepository->max('last_login_at');
orderBy($column, $order = 'DESC')
Retorna registros ordenados por uma coluna.
$clientes = $clientRepository->orderBy('nome', 'ASC'); $recentes = $clientRepository->orderBy('created_at', 'DESC');
dataTable()
Retorna todos os registros para uso em tabelas (com cache).
$dados = $clientRepository->dataTable();
Modificadores encadeáveis
latest(string $column = 'created_at')
Ordena de forma descendente pela coluna informada. Encadeável com get(), first(), limit(), etc.
$recentes = $clientRepository->latest()->get(); $ultimosAtualizados = $clientRepository->latest('updated_at')->limit(10)->get();
oldest(string $column = 'created_at')
Ordena de forma ascendente pela coluna informada.
$primeiros = $clientRepository->oldest()->get(); $clientRepository->oldest('updated_at')->limit(5)->get();
limit(int $value)
Limita o número de registros retornados. Funciona com qualquer método terminal.
$top10 = $clientRepository->limit(10)->get(); $ultimos5 = $clientRepository->latest()->limit(5)->get(); $excluidos = $clientRepository->onlyTrashed()->limit(3)->get();
select(array $columns)
Seleciona apenas as colunas informadas. Sempre inclui id automaticamente.
Suporta notação de JSON (tabela.chave) para campos JSONB no PostgreSQL.
$clients = $clientRepository->select(['nome', 'email'])->get(); // JSON field (PostgreSQL) $clients = $clientRepository->select(['meta.cidade', 'nome'])->get(); // gera: "meta"->>'cidade' as "meta.cidade"
relationships(...$relationships)
Carrega relacionamentos Eloquent junto com os registros.
Quando useTrashed(true) está ativo, os relacionamentos também incluem registros excluídos.
$clients = $clientRepository->relationships('pedidos', 'enderecos')->get(); // Com soft deletes nos relacionamentos $clients = $clientRepository ->useTrashed(true) ->relationships('pedidos', 'enderecos') ->get();
withCount(string|array $relations)
Adiciona a contagem de relacionamentos sem carregá-los. Disponível como {relation}_count em cada registro.
$clients = $clientRepository->withCount('pedidos')->get(); // $client->pedidos_count $clients = $clientRepository->withCount(['pedidos', 'enderecos'])->get(); // $client->pedidos_count, $client->enderecos_count
withoutCache()
Pula o cache para a próxima operação terminal, indo direto ao banco. O cache não é invalidado — apenas ignorado nessa chamada. Útil para contextos críticos como pós-pagamento ou relatórios em tempo real.
$client = $clientRepository->withoutCache()->findById(1); $clients = $clientRepository->withoutCache()->get(); $clientRepository->withoutCache()->paginate(20);
setTags(array $tags)
Define tags adicionais para segmentação do cache (somente drivers com suporte a tags).
$clientRepository->setTags(['empresa:5'])->get();
whereDate($column, $operator, $value)
Filtra por data. Encadeável com outros métodos.
$recent = $clientRepository->latest()->whereDate('created_at', '>=', '2024-01-01')->get();
whereIn($column, array $values)
Filtra por múltiplos valores. Encadeável.
$selected = $clientRepository->whereIn('status', ['ativo', 'premium'])->limit(10)->get();
whereBetween($column, array $values)
Filtra por faixa de valores. Encadeável.
$midRange = $orderRepository->whereBetween('valor', [100, 500])->get();
groupBy($columns)
Agrupa resultados. Encadeável com agregações.
$summary = $repository->select(['status', DB::raw('COUNT(*) as total')]) ->groupBy('status') ->get();
Scopes customizados
Defina filtros reutilizáveis como métodos scope{Nome}() no próprio repositório — o mesmo padrão dos local scopes do Eloquent, só que morando no repositório.
class ClientRepository extends BaseRepository implements ClientRepositoryInterface { public function entity(): string { return Client::class; } protected function scopeAtivos($query) { // O `return` é OPCIONAL — pode mutar o builder por referência, igual ao Eloquent. return $query->where('status', 'ativo'); } protected function scopeComSaldoMinimo($query, float $minimo) { return $query->where('saldo', '>=', $minimo); } }
scope(string $nome, ...$parametros)
Aplica um scope definido no repositório. Encadeável e composável com os demais modificadores. Lança BadMethodCallException se o scope não existir.
$ativos = $clientRepository->scope('ativos')->get(); // Com parâmetros $vip = $clientRepository->scope('comSaldoMinimo', 1000)->get(); // Encadeando vários scopes e modificadores (a ordem não importa) $resultado = $clientRepository ->scope('ativos') ->scope('comSaldoMinimo', 500) ->latest() ->get();
$defaultScopes — scopes aplicados automaticamente em toda query
Declare na subclasse os scopes que devem valer sempre, sem precisar chamar scope() a cada consulta (equivalente aos global scopes do Eloquent, no nível do repositório). São aplicados uma única vez no método terminal, apenas em queries de model (views materializadas usam applyViewScope()).
class ClientRepository extends BaseRepository implements ClientRepositoryInterface { // Sem parâmetros: 'ativos' entra em TODA query protected array $defaultScopes = ['ativos']; // Com parâmetros: ['nome' => [args]] // protected array $defaultScopes = ['comSaldoMinimo' => [1000]]; public function entity(): string { return Client::class; } protected function scopeAtivos($query) { return $query->where('status', 'ativo'); } }
$clientRepository->get(); // 'ativos' aplicado automaticamente $clientRepository->scope('comSaldoMinimo', 500)->get(); // 'ativos' + 'comSaldoMinimo' $clientRepository->where('cidade', 'SP')->first(); // 'ativos' aplicado automaticamente
withoutScope(string ...$nomes)
Ignora um ou mais $defaultScopes somente nesta operação (equivalente ao withoutGlobalScope() do Eloquent). O estado é resetado após a chamada — não vaza para a próxima. Como muda o resultado, integra a chave de cache.
// Relatório administrativo que precisa ver inativos também $todos = $clientRepository->withoutScope('ativos')->get(); // Ignora vários default scopes de uma vez $clientRepository->withoutScope('ativos', 'doTenant')->get();
Paginação
paginate(int $totalPage = 10)
Paginação dinâmica baseada em parâmetros do request. Protegida contra SQL Injection via allowedSortColumns.
Parâmetros aceitos via request:
| Parâmetro | Descrição |
|---|---|
pagesize |
Registros por página (padrão: $totalPage) |
search |
Texto para busca (ILIKE) |
searchable_fields |
Array de colunas onde a busca é aplicada |
sort_column |
Coluna de ordenação (validada contra whitelist) |
sort_direction |
asc ou desc (padrão: asc) |
// No Controller $result = $clientRepository->paginate(15); // Com onlyTrashed $result = $clientRepository->onlyTrashed()->paginate(10); // Retorno [ 'data' => [...], // registros da página atual 'recordsFiltered' => 200, // total filtrado 'recordsTotal' => 200, // total geral 'totalPages' => 14, // total de páginas 'perPage' => 15, // registros por página 'current_page' => 1, // página atual ]
Soft Deletes
useTrashed(bool $permission)
Inclui registros soft-deleted nos resultados (equivalente ao withTrashed do Eloquent).
// Todos os registros, incluindo excluídos $todos = $clientRepository->useTrashed(true)->get(); // Somente ativos (comportamento padrão) $ativos = $clientRepository->useTrashed(false)->get();
onlyTrashed()
Retorna somente os registros que foram soft-deleted (deleted_at IS NOT NULL).
Lança RuntimeException se o model não usar a trait SoftDeletes.
Compatível com: get(), first(), findById(), findWhere(), findWhereCustom(), paginate(), count(), exists(), pluck(), sum(), avg(), min(), max(), chunk(), limit(), latest(), oldest().
$excluidos = $clientRepository->onlyTrashed()->get(); $primeiro = $clientRepository->onlyTrashed()->first(); $total = $clientRepository->onlyTrashed()->count(); $pagina = $clientRepository->onlyTrashed()->paginate(15); $emails = $clientRepository->onlyTrashed()->pluck('email'); $recentes = $clientRepository->onlyTrashed()->latest('deleted_at')->limit(10)->get(); $clientRepository->onlyTrashed()->chunk(200, function ($lote) { foreach ($lote as $client) { // processar... } });
Escrita
transaction(callable $callback, int $attempts = 1)
Executa o callback dentro de uma transação na conexão do model, agrupando várias operações atomicamente. Retorna o valor do callback; em deadlock, reexecuta até $attempts vezes.
$pedido = $pedidoRepository->transaction(function () use ($pedidoRepository, $itemRepository) { $pedido = $pedidoRepository->store([...]); $itemRepository->storeMany([...]); return $pedido; });
Os jobs de cache (
RegenerateCacheJob/RefreshMaterializedViewsJob) sãoafterCommit: só disparam após o commit da transação. Em rollback, nada de cache é regenerado com dados revertidos.
store(array $data)
Cria um novo registro e invalida o cache.
$client = $clientRepository->store([ 'nome' => 'João Silva', 'email' => 'joao@email.com', 'plano_id' => 1, ]);
storeMany(array $records, bool $useEloquent = false)
Insere múltiplos registros em uma única operação. Muito mais eficiente do que chamar store() em loop.
$useEloquent = false(padrão): usainsert()direto — mais rápido, sem eventos Eloquent, adicionacreated_at/updated_atautomaticamente.$useEloquent = true: usacreate()— mais lento, mas dispara eventos e observers.
// Insert direto (recomendado para grandes volumes) $clientRepository->storeMany([ ['nome' => 'Ana', 'email' => 'ana@email.com'], ['nome' => 'Bob', 'email' => 'bob@email.com'], ['nome' => 'Carl', 'email' => 'carl@email.com'], ]); // Via Eloquent (dispara eventos e observers) $clientRepository->storeMany([ ['nome' => 'Ana', 'email' => 'ana@email.com'], ], useEloquent: true);
update($id, array $data)
Atualiza um registro pelo ID. Busca diretamente no banco (sem cache) para evitar atualizar dados desatualizados.
$clientRepository->update(1, [ 'nome' => 'João Atualizado', 'plano_id' => 2, ]);
updateMany(array $data, array $conditions)
Atualiza múltiplos registros por condições. Executa uma única query UPDATE ... WHERE, sem carregar models em memória. Retorna o número de registros afetados.
// Inativar todos de um plano $afetados = $clientRepository->updateMany( ['status' => 'inativo'], ['plano_id' => 3] ); // Múltiplas condições $clientRepository->updateMany( ['ativo' => false], ['empresa_id' => 10, 'tipo' => 'free'] );
createOrUpdate($id, array $data)
Cria um novo registro se o ID não existir, ou atualiza se existir. A verificação de existência é feita diretamente no banco (sem cache).
$clientRepository->createOrUpdate(1, ['nome' => 'João']); // atualiza $clientRepository->createOrUpdate(99, ['nome' => 'Maria']); // cria
firstOrCreate(array $attributes, array $values = [])
Retorna o primeiro registro que corresponda aos atributos, ou cria um novo.
// Busca por email, cria se não existir $client = $clientRepository->firstOrCreate( ['email' => 'joao@email.com'], ['nome' => 'João', 'telefone' => '1199999999'] ); // Equivalente a: // $client = Client::where('email', 'joao@email.com')->first() ?? Client::create([...])
updateOrCreate(array $attributes, array $values = [])
Atualiza um registro existente ou cria um novo.
// Atualiza se email existe, senão cria $client = $clientRepository->updateOrCreate( ['email' => 'joao@email.com'], ['nome' => 'João Silva', 'telefone' => '11988888888'] ); // Equivalente a: // $client = Client::updateOrCreate(['email' => ...], ['nome' => ...])
duplicate($id, array $modifications = [])
Duplica um registro existente com modificações opcionais.
// Duplica o cliente 1 $newClient = $clientRepository->duplicate(1); // Duplica com modificações $newClient = $clientRepository->duplicate(1, [ 'nome' => 'Cópia do Cliente', 'email' => 'copia@email.com' ]); // IDs e timestamps são automaticamente removidos
increment($id, $column, $amount = 1)
Incrementa uma coluna numericamente (operação atômica).
// +1 na coluna visitas $clientRepository->increment(1, 'visitas'); // +5 na coluna pontos $clientRepository->increment(1, 'pontos', 5); // Útil para contadores: views, likes, downloads $productRepository->increment($productId, 'view_count');
decrement($id, $column, $amount = 1)
Decrementa uma coluna numericamente (operação atômica).
// -1 no estoque $productRepository->decrement(1, 'stock'); // -5 no estoque $productRepository->decrement(1, 'stock', 5); // Útil para controle de estoque if ($productRepository->decrement($id, 'quantity', $amount)) { // Estoque decrementado com sucesso } else { // Produto não encontrado }
chunk(int $size, callable $callback)
Processa grandes volumes de registros em lotes para evitar estouro de memória. Compatível com onlyTrashed() e useTrashed().
// Processar em lotes de 500 $clientRepository->chunk(500, function ($clientes) { foreach ($clientes as $cliente) { // processar cada cliente... } }); // Processar somente excluídos em lotes $clientRepository->onlyTrashed()->chunk(200, function ($excluidos) { foreach ($excluidos as $cliente) { // reprocessar ou auditar... } });
Exclusão
find($id) + delete()
Soft-delete de um registro e seus relacionamentos configurados.
$clientRepository->find(1)->delete();
find($id) + restore()
Restaura um registro soft-deleted e seus relacionamentos.
$clientRepository->find(1)->restore();
find($id) + forceDelete()
Remove permanentemente um registro soft-deleted. Só funciona se o registro já estiver na lixeira.
$clientRepository->find(1)->forceDelete();
Cache avançado
cacheFor() / cacheForHours() / cacheForDays()
Define o TTL da próxima operação (encadeável; resetado após a operação).
$clientRepository->cacheFor(5)->get(); // 5 minutos $clientRepository->cacheForHours(2)->first(); // 2 horas $clientRepository->cacheForDays(1)->findById(1);
cacheIf(callable $condition)
Só grava o resultado no cache se o callback (que recebe o resultado, após a query) retornar true. Útil para não cachear resultados vazios.
// Resultado vazio não é cacheado — a próxima chamada volta ao banco $clientRepository->cacheIf(fn($result) => $result->isNotEmpty())->get();
withCacheTags(array $tags) / setTags(array $tags)
Marca o cache da operação com tags adicionais (além da tag da entidade), criando pontos de invalidação granulares. Requer driver com suporte a tags (Redis/Memcached).
$clientRepository->withCacheTags(['clientes:ativos'])->get();
flushTags(array $tags)
Invalida o cache associado às tags informadas — invalidação granular, sem flush total da entidade. No-op em drivers sem suporte a tags.
$clientRepository->flushTags(['clientes:ativos']);
clearCacheForEntity() e invalidação granular (opt-in)
Toda escrita chama clearCacheForEntity(), que por padrão faz flush total da entidade (seguro: nunca serve dado stale) via flushEntityCache(). Para invalidação granular, sobrescreva flushEntityCache() no repositório, combinando withCacheTags() nas leituras com flushTags() (ou ouvindo os eventos RepositoryCreated/Updated/Deleted, que carregam o model):
class ClientEloquentRepository extends BaseRepository implements ClientRepository { protected function flushEntityCache(): void { $this->flushTags(['clientes:empresa:' . tenant()->id]); } }
Eventos de limpeza de cache
clearCacheForEntity() dispara dois eventos, ambos carregando o repositório ($event->repository):
RepositoryBeforeClearingCacheEvent— antes do flush.RepositoryAfterClearingCacheEvent— depois do flush (dispara mesmo que o agendamento dos jobs de warming/refresh falhe).
use RiseTechApps\Repository\Events\RepositoryAfterClearingCacheEvent; Event::listen(RepositoryAfterClearingCacheEvent::class, function ($event) { Log::info('Cache limpo para ' . $event->getEntityName()); });
Proteção contra loop: um listener desses eventos pode chamar
clearCacheForEntity()novamente (inclusive de uma instância nova do repositório). Um guard de reentrância estático por entidade detecta o ciclo em andamento: a chamada reentrante apenas refaz o flush e retorna, sem re-disparar os eventos nem re-agendar os jobs — quebrando o loopclearCacheForEntity → evento → clearCacheForEntity → .... A trava é liberada emfinally, segura em workers de fila / Octane.
withoutEvents()
Silencia todos os eventos do repositório na próxima operação (encadeável; resetado após a operação). Vale para os eventos de escrita (RepositoryCreating/Created, RepositoryUpdating/Updated, RepositoryDeleting/Deleted) e os de limpeza de cache. Como os eventos de escrita não rodam, o veto de listeners (shouldCreate/shouldUpdate/shouldDelete) também não se aplica — a operação segue sem bloqueio.
$clientRepository->withoutEvents()->store($data); $clientRepository->withoutEvents()->update($id, $data); $clientRepository->withoutEvents()->find($id)->delete();
Não confunda com o guard de reentrância acima: o guard protege contra loop sempre, mesmo sem
withoutEvents(). OwithoutEvents()é controle explícito de quem chama o repositório.
Cache warming
Após uma escrita, o RegenerateCacheJob re-aquece o cache recém-limpo. Controlado por config:
// config/repository.php 'cache' => [ 'warming_enabled' => true, // false = rebuild lazy no próximo read 'warming_methods' => ['get', 'first'], // aceita: get, first, dataTable ],
🛡️ Segurança
Sanitização de input
store()/update() sanitizam strings (remoção de tags HTML) conforme config('repository.sanitization').
Validação de coluna (SQL injection)
Em buscas com SQL raw — fuzzySearch(), searchFullText(), findWhereJson() — os valores já vão por binding. Os nomes de coluna (que não podem ser bindados) são validados em duas camadas:
- Identificador — só
^[a-zA-Z_][a-zA-Z0-9_]*$, o que impede fechar aspas/injetar SQL. - Whitelist —
$allowedColumns(se definida) ou as colunas reais da tabela (Schema::getColumnListing).
Coluna inválida ou injeção → InvalidFilterException.
class ClientEloquentRepository extends BaseRepository implements ClientRepository { // Opcional: restringe ainda mais as colunas aceitas em buscas raw protected array $allowedColumns = ['nome', 'email']; }
No
findWhereJson(), apenas a coluna base é validada contra o schema; as chaves do JSON podem ser arbitrárias (vão pelo operador JSON nativo do builder, que as escapa com segurança).
Ordenação no paginate()
sort_column é validado contra $allowedSortColumns (fallback seguro id).
📈 Métricas
getMetrics()
Retorna estatísticas de uso (queries, cache hit rate, slow queries, etc.).
$metrics = $clientRepository->getMetrics(); // ['total_queries' => 12, 'cache_hit_rate' => 83.33, 'avg_query_time' => 1.4, ...]
resetMetrics()
Zera as métricas acumuladas. As métricas são estáticas (compartilhadas por todos os repositórios no processo); em workers long-running (Octane, queue daemon) elas acumulam entre requests/jobs. Chame no início de cada ciclo para ter métricas isoladas.
ClientEloquentRepository::resetMetrics();
enableSlowQueryLog(int $threshold)
Loga queries acima do threshold (ms) na próxima operação.
$clientRepository->enableSlowQueryLog(100)->get();
Materialized Views (PostgreSQL)
Permitem pré-calcular e cachear consultas complexas diretamente no banco, com refresh controlado pela aplicação.
registerViews() — configuração na subclasse
Opcional: repositórios sem views materializadas não precisam implementar (o default retorna []). Sobrescreva apenas quando o repositório usar views:
class RelatorioPedidoRepository extends BaseRepository { public function entity(): string { return Pedido::class; } protected function registerViews(): array { return [ 'vw_pedidos_resumo' => " SELECT cliente_id, COUNT(*) as total_pedidos, SUM(valor) as faturamento FROM pedidos WHERE deleted_at IS NULL GROUP BY cliente_id ", ]; } }
useMaterializedView(string $view)
Direciona as próximas queries para a view materializada em vez da tabela principal.
Bloqueada automaticamente quando onlyTrashed() ou useTrashed(true) está ativo.
$resumo = $relatorioRepository ->useMaterializedView('vw_pedidos_resumo') ->get(); $item = $relatorioRepository ->useMaterializedView('vw_pedidos_resumo') ->findWhereFirst('cliente_id', 5);
createMaterializedViews()
Cria todas as views registradas em registerViews() caso ainda não existam no banco.
$relatorioRepository->createMaterializedViews();
refreshMaterializedViews(?string $view = null, bool $concurrently = true)
Atualiza os dados das views. Por padrão usa CONCURRENTLY para não bloquear leituras.
Dispara BeforeRefreshMaterializedViewsJobEvent antes e AfterRefreshMaterializedViewsJobEvent depois.
// Refresh de todas as views registradas $relatorioRepository->refreshMaterializedViews(); // Refresh de uma view específica $relatorioRepository->refreshMaterializedViews('vw_pedidos_resumo'); // Sem CONCURRENTLY (necessário na primeira vez, antes de criar índice único) $relatorioRepository->refreshMaterializedViews(concurrently: false);
cleanMaterializedView()
Remove todas as views materializadas registradas.
$relatorioRepository->cleanMaterializedView();
🔗 Encadeamento
Os métodos encadeáveis podem ser combinados livremente. O escopo é sempre resetado automaticamente após a operação terminal, evitando vazamento de estado entre chamadas.
// Últimos 10 clientes excluídos, com contagem de pedidos $clientRepository ->onlyTrashed() ->withCount('pedidos') ->latest('deleted_at') ->limit(10) ->get(); // Relatório sem cache com relacionamentos $clientRepository ->withoutCache() ->relationships('enderecos', 'pedidos') ->select(['id', 'nome', 'email']) ->paginate(25); // Busca avançada com filtros customizados $clientRepository ->useTrashed(true) ->findWhereCustom([ ['column' => 'plano_id', 'operator' => 'IN', 'value' => [1, 2, 3]], ['column' => 'created_at','operator' => 'BETWEEN', 'value' => ['2024-01-01', '2024-12-31']], ]); // Scopes compostos (default scope 'ativos' entra automaticamente) $clientRepository ->scope('comSaldoMinimo', 1000) ->select(['id', 'nome', 'saldo']) ->latest() ->get();
🛠 Contribuição
Sinta-se à vontade para contribuir! Basta seguir estes passos:
- Faça um fork do repositório
- Crie uma branch (
feature/nova-funcionalidade) - Faça um commit das suas alterações
- Envie um Pull Request
📜 Licença
Este projeto é distribuído sob a licença MIT. Veja o arquivo LICENSE para mais detalhes.
📊 Materialized Views (PostgreSQL)
As Materialized Views permitem pré-calcular e cachear consultas complexas diretamente no PostgreSQL, com refresh controlado pela aplicação e cache adicional na camada de aplicação.
⚙️ Requisitos
- PostgreSQL 12+ (para suporte completo a Materialized Views)
- Extensão
pg_trgmpara fuzzy search (opcional) - Driver de cache que suporte tags (Redis ou Memcached) recomendado
📝 Exemplo Completo
1. Definindo a View no Repository
Forma Recomendada (Query Builder):
class RelatorioVendasRepository extends BaseRepository { public function entity(): string { return Pedido::class; } /** * Registra as views materializadas usando Query Builder. * Mais seguro, com autocomplete do IDE e type safety. */ protected function registerViews(): array { return [ // Usando Query Builder ✅ $this->view('vw_vendas_por_cliente', function ($query) { return $query->select([ 'cliente_id', DB::raw('COUNT(*) as total_pedidos'), DB::raw('SUM(valor) as faturamento_total'), DB::raw('MIN(valor) as menor_pedido'), DB::raw('MAX(valor) as maior_pedido'), DB::raw('AVG(valor) as ticket_medio'), ]) ->whereNull('deleted_at') ->groupBy('cliente_id'); }), // Com joins $this->view('vw_pedidos_com_cliente', function ($query) { return $query ->select([ 'pedidos.*', 'clientes.nome as cliente_nome', 'clientes.email as cliente_email', ]) ->join('clientes', 'pedidos.cliente_id', '=', 'clientes.id') ->whereNull('pedidos.deleted_at'); }), // Também suporta SQL string (legado) ⚠️ // 'vw_outra_view' => 'SELECT * FROM pedidos WHERE status = \'ativo\'', ]; } }
Vantagens do Query Builder:
- ✅ Autocompleto do IDE para colunas e métodos
- ✅ Type safety - Erros detectados em tempo de compilação
- ✅ Escapamento automático - Proteção contra SQL injection
- ✅ Fácil manutenção - Refatoração segura
- ✅ Portabilidade - Funciona com diferentes drivers de banco
#### 2. Usando a View Materializada
```php
$repository = app(RelatorioVendasRepository::class);
// Cria a view automaticamente se não existir
$repository->createMaterializedViews();
// Usa a view para consultas (cacheado)
$vendasPorCliente = $repository
->useMaterializedView('vw_vendas_por_cliente')
->get();
// Busca específica na view
$cliente = $repository
->useMaterializedView('vw_vendas_por_cliente')
->findWhereFirst('cliente_id', 123);
// Paginação com cache
$paginado = $repository
->useMaterializedView('vw_vendas_por_cliente')
->orderBy('faturamento_total', 'DESC')
->paginate(20);
3. Atualizando a View (Refresh)
// Refresh de todas as views registradas $repository->refreshMaterializedViews(); // Refresh de uma view específica $repository->refreshMaterializedViews('vw_vendas_por_cliente'); // Refresh sem CONCURRENTLY (útil na primeira vez ou sem índice único) $repository->refreshMaterializedViews(concurrently: false);
4. Schedule Automático
Adicione ao routes/console.php ou App\Console\Kernel.php:
use Illuminate\Support\Facades\Schedule; use App\Repositories\RelatorioVendasRepository; // Atualiza a view a cada hora Schedule::call(function () { app(RelatorioVendasRepository::class)->refreshMaterializedViews(); })->hourly(); // Ou use o comando Artisan Schedule::command('repository:refresh-materialized-views RelatorioVendas')->hourly();
5. Comando Artisan
# Cria as views se não existirem php artisan repository:create-materialized-views RelatorioVendasRepository # Atualiza as views php artisan repository:refresh-materialized-views RelatorioVendasRepository # Remove e recria as views php artisan repository:restart-materialized-views RelatorioVendasRepository
🔄 Eventos de Refresh
O package dispara eventos durante o refresh:
// Antes de atualizar qualquer view Event::listen(\RiseTechApps\Repository\Events\BeforeRefreshAllMaterializedViewsJobEvent::class, function () { Log::info('Iniciando refresh de todas as views...'); }); // Antes de cada view Event::listen(\RiseTechApps\Repository\Events\BeforeRefreshMaterializedViewsJobEvent::class, function ($event) { Log::info("Atualizando view: {$event->viewName}"); }); // Depois de cada view Event::listen(\RiseTechApps\Repository\Events\AfterRefreshMaterializedViewsJobEvent::class, function ($event) { Log::info("View atualizada: {$event->viewName}"); }); // Depois de todas Event::listen(\RiseTechApps\Repository\Events\AfterRefreshAllMaterializedViewsJobEvent::class, function () { Log::info('Todas as views foram atualizadas'); });
📈 Performance
Sem Materialized View:
Query: SELECT ... GROUP BY cliente_id (tabela com 1M registros)
Tempo: ~500ms a cada consulta
Com Materialized View:
Primeira consulta: ~500ms (pré-calculada no banco)
Consultas subsequentes: ~5ms (cache da aplicação)
Speedup: 100x
⚠️ Limitações
-
Não funciona com soft deletes: Views materializadas não incluem registros excluídos (
deleted_at IS NOT NULL).// ❌ Não funciona $repository->onlyTrashed()->useMaterializedView('vw_xxx')->get(); // ✅ Usa a tabela normal $repository->onlyTrashed()->get();
-
Dados podem estar desatualizados: O refresh é manual ou agendado.
-
Requer PostgreSQL: MySQL não suporta Materialized Views nativamente.
-
Cache: Recomenda-se usar Redis/Memcached para melhor performance com tags.
🏢 Isolamento nas Views (multi-tenancy, etc.)
O package é agnóstico de tenancy — não conhece SharingPolicy, sub_tenant nem nenhuma regra de isolamento. Em vez disso, expõe um hook que o repositório pode sobrescrever para aplicar o filtro que quiser sobre a query das views materializadas:
// BaseRepository — default: não filtra nada protected function applyViewScope(\Illuminate\Database\Query\Builder $query): \Illuminate\Database\Query\Builder { return $query; }
- Projeto sem isolamento → não faz nada (sem erro).
- Projeto com isolamento → sobrescreve
applyViewScope()no repositório (diretamente ou via trait reutilizável), aplicando o filtro.
class PedidoEloquentRepository extends BaseRepository implements PedidoRepository { protected function applyViewScope($query) { if (!subTenancy()->isInitialized()) { return $query->whereRaw('1 = 0'); // falha segura } return $query->where('sub_tenant_id', subTenancy()->getKey()); } }
No caminho Eloquent (
get/first/where/...), o isolamento continua vindo dos global scopes do próprio model — o repositório não interfere. OapplyViewScope()cobre só o caminho das views materializadas (DB::table), que não passa por global scopes.Para projetos que usam
risetechapps/tenancy-for-laravel, a regra de isolamento (RESTRICTED / USER_FILIALS / ALL_FILIALS) vive fora deste package, como uma trait que sobrescreveapplyViewScope(). Ver osuggestnocomposer.json.
💡 Desenvolvido por Rise Tech