Tehilim integration#
Tehilim is a schema-first database toolkit for PHP by the same author as Relayer. It gives a Prisma-like experience but skips ORM class mapping: data stays as associative arrays and types are PHPDoc array shapes, leaving type safety to PHPStan.
It is separate from Relayer's built-in database layer (the DATABASE_DSN PDO wrapper) — not a replacement, but something you can run alongside it. The integration point is the profiler hook: Tehilim's measuring hook is designed to match the signature of Relayer's profiler Profiler::measure(), so it plugs straight in with no adapter.
Tehilim is at v0.1. It is practical for prototyping and small-to-medium apps, but the API may still change.
Install and generate#
composer require polidog/tehilim
vendor/bin/tehilim init # generate a starter schema
vendor/bin/tehilim generate # generate the typed client
vendor/bin/tehilim migrate dev --name init # create & apply a migrationDeclare your data model in schema.tehilim, then generate emits a typed client (App\Generated\TehilimClient by default). Results come back as associative arrays whose exact shape PHPStan understands.
Wiring it into a Relayer app (DI)#
Bind the generated TehilimClient into the DI container and pages / components can receive it by type. As with this repo's DocStoreFactory, the factory reads no environment variables (it is pure): connection details are resolved from %env()% placeholders in services.yaml and injected as arguments. Profiling is a dev-only concern, so the Profiler is injected via env-specific config (when@dev) only in dev — the production service definition never references the Profiler at all.
<?php
// src/Db/TehilimClientFactory.php
namespace App\Db;
use App\Generated\TehilimClient;
use Polidog\Relayer\Profiler\Profiler;
final class TehilimClientFactory
{
// Everything arrives as arguments — never $_ENV / getenv.
// $profiler is injected only in dev (null in production).
public static function create(
string $dsn,
string $user,
string $password,
?Profiler $profiler = null,
): TehilimClient {
$pdo = new \PDO(
$dsn,
'' !== $user ? $user : null,
'' !== $password ? $password : null,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION],
);
$client = TehilimClient::fromPdo($pdo);
// Pass measure() as a first-class callable. The Profiler only
// arrives in dev, so the measuring code is never on the
// production client.
return null !== $profiler
? $client->withProfiler($profiler->measure(...))
: $client;
}
}services.yaml resolves the %env()% values and passes them in (falling back to an empty string when unset, so it never throws). The production definition carries only the connection details — the Profiler is added as a fourth argument only in dev, via a when@dev block (see the "environment-specific config" section of services & DI for how env-aware loading works).
# config/services.yaml
parameters:
# empty when unset (no throw on dev/CI without DATABASE_*)
env(DATABASE_DSN): ''
env(DATABASE_USER): ''
env(DATABASE_PASSWORD): ''
app.database_dsn: '%env(string:DATABASE_DSN)%'
app.database_user: '%env(string:DATABASE_USER)%'
app.database_password: '%env(string:DATABASE_PASSWORD)%'
services:
# Production client — no Profiler reference
App\Generated\TehilimClient:
factory: ['App\Db\TehilimClientFactory', 'create']
arguments:
- '%app.database_dsn%'
- '%app.database_user%'
- '%app.database_password%'
autowire: false
# Add the Profiler as a fourth argument in dev only
when@dev:
services:
App\Generated\TehilimClient:
factory: ['App\Db\TehilimClientFactory', 'create']
arguments:
- '%app.database_dsn%'
- '%app.database_user%'
- '%app.database_password%'
- '@Polidog\Relayer\Profiler\Profiler'
autowire: falseThe right-hand side is a %env()% placeholder, not a string baked at configure time, so dumping the container with vendor/bin/relayer container:compile is safe — the dump expands these into per-request getEnv() calls, so runtime-injected values (Fly secrets, etc.) resolve correctly on every request (see services & DI and CLI commands).
Pages then just take the type in the constructor.
<?php
use App\Generated\TehilimClient;
use Polidog\Relayer\Router\Component\PageComponent;
use Polidog\UsePhp\Runtime\Element;
final class UsersPage extends PageComponent
{
public function __construct(private readonly TehilimClient $db) {}
public function render(): Element
{
$users = $this->db->user->findMany([
'include' => ['posts' => ['where' => ['published' => true]]],
'orderBy' => ['id' => 'asc'],
]);
return <ul>{array_map(fn ($u) => <li>{$u['name']}</li>, $users)}</ul>;
}
}The profiler hook#
Tehilim's measuring hook has the shape function (string $collector, string $label, callable $fn): mixed, which exactly matches Relayer's Profiler::measure(string $collector, string $label, callable $fn): mixed. So there is no decorator to write — passing measure() as a first-class callable is all it takes, which is the single withProfiler() line in the factory above.
As shown above, the Profiler is injected only by the when@dev block, so the hook is attached in dev only. The production service definition never references the Profiler, so the measuring code is not on the production client at all.
In dev, each Tehilim operation then shows up as a span on the /_profiler timeline. The measured operations are findUnique / findFirst / findMany, insert / update / delete / count, and upsert / insertMany / updateMany / deleteMany. Cache hits are skipped.
measure() records only timing — never arguments or return values (the same behavior as the profiler).
Pass null to detach a hook you attached in dev.
$db->withProfiler(null); // clear the measuring hookA non-Relayer hook (optional)#
withProfiler() accepts any callable, so you can route to your own log instead of the Relayer profiler.
$db->withProfiler(function (string $collector, string $label, callable $fn) {
$start = hrtime(true);
try {
return $fn();
} finally {
error_log(sprintf('[%s/%s] %.2fms', $collector, $label, (hrtime(true) - $start) / 1e6));
}
});See also#
- Database — Relayer's built-in
Database(DATABASE_DSN).
The request-scoped read cache is a feature of that layer, separate from Tehilim.
- Profiler —
measure(), traceable decorators, and how to
read /_profiler.
- Tehilim repository — details on the
schema syntax, relations, and migrations.
Change history (3)
- v0.23.0: profiler wiring via dev-only when@dev DI
- DI: factory takes injected args, no $_ENV access (mirror DocStoreFactory)
- Add English translation of Tehilim integration page