Install and confirm the surface area first. This is a small package (14 stars, MIT), so before designing anything around it, spend five minutes verifying exactly what it registers:
composer require sulimanbenhalim/laravel-prose
php artisan vendor:publish --tag=prose-config # confirm the tag in the README
Laravel auto-discovery should register the service provider. Then check vendor/sulimanbenhalim/laravel-prose/src/ for the provider — the thing you care about is which extension point it uses: a Builder macro (->toProse()), a Prose::describe($query) facade, or a model trait. All three shapes appear in packages like this, and the rest of your integration depends on which one you get.
First call to make:
$prose = User::query()
->where('active', true)
->where('created_at', '>=', now()->subDays(30))
->orderBy('name')
->limit(10)
->toProse();
// "Active users created in the last 30 days, ordered by name, limited to 10."
First real use case: saved filters in an admin panel. Any app with a "segments" or "saved views" feature eventually has users asking "what does this filter actually do?" Rendering the builder through the package gives you a self-documenting label for free, and it stays in sync because it is derived from the query object rather than a hand-written string.
Secondary first use cases, in order of payoff: attaching human-readable context to slow-query log entries, and generating internal endpoint documentation from the builders in your repositories.
Centralize the call in one place. Don't scatter ->toProse() through controllers. Wrap it so you can swap in toSql() as a fallback and control redaction:
final class QueryDescriber
{
public function describe(Builder $query, bool $redact = true): string
{
try {
$prose = $query->toProse();
} catch (\Throwable $e) {
report($e);
return 'Custom query (description unavailable)';
}
return $redact ? $this->redact($prose) : $prose;
}
}
Saved-filter labels. Store the builder-affecting state (filters, sorts) as JSON, rebuild the builder from it, then describe it. The prose is a projection of the builder, never the source of truth:
$query = $this->applyFilters(User::query(), $segment->filters);
return [
'id' => $segment->id,
'name' => $segment->name,
'summary' => $this->describer->describe($query),
'sql' => $query->toSql(), // always ship this alongside in admin UIs
];
Slow-query and audit logging. In a DB::listen handler you only have raw SQL, so this package is more useful upstream — wrap your repository methods and log the prose next to the timing:
return tap($this->repository->expensiveReport($params), function ($result) use ($params) {
Log::channel('audit')->info('Report generated', [
'query' => $this->describer->describe($this->repository->lastBuilder()),
'params' => $params,
]);
});
Local scopes are your friend, and they get described. Because scopes mutate the builder, User::active()->recent()->toProse() produces a sentence covering the expanded wheres. That makes scopes testable as prose:
public function test_active_scope_is_described(): void
{
$this->assertStringContainsString(
'active',
User::query()->active()->toProse()
);
}
Use this sparingly — asserting on sentences is brittle. Assert on substrings for the handful of scopes whose semantics are non-obvious, and don't build a whole test suite around prose output.
API responses for filter builders. If you have a UI where users compose conditions, describe the draft builder on every change so the user sees "Users in Germany, verified, with at least one order" before saving. Debounce it; the description is cheap but not free.
Docs generation command. A scheduled or on-demand artisan command that walks a registry of named queries and dumps prose into a markdown file is a genuinely good fit for this package — it gives non-engineers a readable catalog of what your reporting layer does.
email = ? or the actual bound value. If values are inlined, treat every description as potentially containing PII and redact before it reaches logs, Slack, or an error tracker. Verify this early with a query containing a real email address — it changes your entire integration.whereRaw, selectRaw, DB::raw are opaque. Any raw expression will either be skipped or dumped verbatim. Audit your repositories for raw clauses before assuming descriptions are complete; a silently incomplete description is worse than none.active() global scopes are appended to every builder, so descriptions may say "excluding deleted records" or "for tenant 42" on every single line. Decide whether that's signal or noise, and strip it in your wrapper if it's noise.whereHas. Deeply nested whereHas('orders.items', fn ($q) => ...) structures are where prose generators degrade into unreadable run-ons. Cap the depth in your wrapper, or fall back to SQL when the description exceeds ~200 characters.groupBy / having / union are the weak spots. These are the constructs most likely to produce vague output ("grouped records"). If your reporting layer leans on aggregates, test those paths explicitly before shipping prose to end users.composer require sulimanbenhalim/laravel-prose:^x.y with a committed composer.lock, and check the provider for Laravel version guards before upgrading the framework.QueryDescriber so you retain a single place to swap the implementation — including replacing it with a hand-rolled describer if the package stalls.toSql() in developer-facing contexts. Prose is for humans deciding what a filter means; SQL is for humans debugging why it's slow. Never let prose replace the SQL view in an admin tool.How can I help you explore Laravel packages today?