michael-rubel/laravel-sql-function-repository
A lightweight Laravel package that brings the repository pattern to SQL functions, letting you call database functions and stored procedures through clean, testable classes with bound parameters instead of scattered raw queries.
The package is a thin, single-purpose abstraction: a repository class that executes PostgreSQL stored functions/procedures through Laravel's connection layer and binds parameters by name. It is archived (7 stars), so treat it as a reference implementation you vendor or fork rather than something you build a long-term architecture around. The 1.1.2 release (a compatibility bump adding Laravel 10 support) is the last meaningful activity — it does not change the archived status or the API surface.
Minimal steps:
composer require michael-rubel/laravel-sql-function-repository:^1.1.2 — pin at least 1.1.2, which is the first release with Laravel 10 support in its constraints; 1.1.1 and below will cap you at older Laravel versions. Laravel 11/12 compatibility is still not guaranteed on an archived 2023 release. If it conflicts, copy the repository class into app/Repositories and keep the license header.SETOF, RETURNS TABLE, composite types) and will not work against MySQL/SQLite.EXECUTE on it:
\df+ my_schema.get_active_users
use MichaelRubel\SqlFunctionRepository\SQLFunctionRepository;
$repository = new SQLFunctionRepository();
$rows = $repository->call('get_active_users', [
'p_since' => now()->subDays(30)->toDateString(),
'p_limit' => 100,
]);
Look first at the package's src/ — there is very little of it. The value is the named-parameter binding (:p_since), which avoids positional-argument mistakes when a function has five-plus parameters. Everything else (result mapping, typing, caching) you supply.
One repository per function, not one generic wrapper. The package's base class is generic, but the useful pattern is to subclass it so the function name and parameter contract are fixed at the class level:
final class ActiveUsersRepository extends SQLFunctionRepository
{
public function forPeriod(CarbonInterface $since, int $limit = 100): Collection
{
return collect($this->call('get_active_users', [
'p_since' => $since->toDateString(),
'p_limit' => $limit,
]));
}
}
Subclasses give you a place for argument validation, casting, and a stable API for the rest of the app. Inject these repositories into services/actions; don't new them in controllers.
Wrap calls in typed DTOs when the function returns SETOF or RETURNS TABLE. PDO hands back stdClass rows with driver-dependent scalar types. Map immediately:
return collect($rows)->map(fn ($row) => new ActiveUser(
id: (int) $row->id,
name: (string) $row->name,
lastSeenAt: Carbon::parse($row->last_seen_at),
));
Scalar and composite returns. SELECT * FROM fn(...) works for set-returning functions; for scalar functions prefer SELECT fn(:arg) AS result and read $rows[0]->result. For composite (RETURNS record) functions you generally need to expand fields explicitly: SELECT (fn(:arg)).*. Encapsulate that variation inside the repository subclass so callers see one method.
Transactions and read consistency. A function runs inside whatever transaction is open on the connection. If a function performs writes, wrap the repository call in DB::transaction(...) at the caller, not inside the repository — the repository should not own the transaction boundary. Conversely, don't call write-performing functions against a read replica connection.
Integration with Eloquent. These repositories don't hydrate models or support relations. Common hybrid: the function returns IDs or a filtered row set, then Model::whereIn('id', $ids)->with('relations')->get() re-hydrates. That's usually faster than making the function return every column.
Testing. Point the test suite at a real PostgreSQL database in CI (service container or pg_tmp). RefreshDatabase plus migrations that create the functions works, but functions are often owned by a separate migration track — keep a database/sql/functions/ directory and a migration that DB::unprepared(file_get_contents(...)). Mock-free tests of the repository are then straightforward.
^1.1.2, and know what it bought you. 1.1.2 only relaxed the Laravel constraint to allow Laravel 10 — no API changes, no bug fixes, no deprecations, so upgrading from 1.1.1 is a composer update with no code migration. It does not signal renewed maintenance: Laravel 11+ is still unsupported, and any CI you inherit from a fork needs the constraint widened yourself.DB::select('select * from get_active_users(:p_since, :p_limit)', [...]) does the same thing. Justify the dependency by the repository-per-function pattern above, not by the package itself. If you inline it, you delete the maintenance risk for free.:memory:) will break on named parameters and PG syntax. Guard those tests or mark the suite PG-required.fn(:p_since::date) — or cast in PHP before binding.get_users(text) and get_users(text, int) both exist, an omitted parameter silently resolves to the wrong one rather than erroring. Always pass every parameter the function expects, or name the function more specifically in the DB.search_path. Unqualified function names resolve against search_path. Qualify with the schema (my_schema.get_active_users) in the repository so behavior doesn't change between environments where search_path differs.EXECUTE is granted per function. New functions added by a migration won't be callable by the app user until GRANT EXECUTE ON FUNCTION ... TO app_user runs. Add the grant to the same migration that creates the function.call() in your subclass with DB::listen, or temporarily replace with DB::select and ->toRawSql() (Laravel 10+). Errors surface as QueryException with the function name but rarely the argument types — check pg_stat_activity and \df when a call fails with "function does not exist".LogQueries middleware, and a contract interface per repository so consumers depend on the interface rather than the concrete class. The package intentionally leaves all of this to you.How can I help you explore Laravel packages today?