makinacorpus/goat-query-bundle
Symfony bundle integrating MakinaCorpus Goat Query, a lightweight SQL query builder. Configure database connections and compose fluent, parameterized SELECT, INSERT, UPDATE, and DELETE queries through a clean API, simplifying data access in Symfony applications.
Install and register the bundle
composer require makinacorpus/goat-query-bundle
The bundle wraps the standalone makinacorpus/goat-query library, which is a backend-agnostic query builder (the same query objects can target SQL drivers and, depending on version, non-SQL backends). You almost always need the core library's documented API in addition to the bundle's glue code.
config/bundles.php; otherwise add Makinacorpus\GoatQueryBundle\GoatQueryBundle::class => ['all' => true].AppKernel::registerBundles().Find the configuration shape before writing YAML
Because this package has effectively no adoption (0 stars, 0 dependents), do not trust blog posts — read the source in this order:
src/DependencyInjection/Configuration.php — the exact config tree (connections, dsn, driver, etc.).src/DependencyInjection/GoatQueryBundleExtension.php — which config keys map to which service IDs.src/Resources/config/services.* — the public service aliases you can inject.First use case: a read query
# config/packages/goat_query.yaml
goat_query:
connections:
default:
driver: pgsql # or mysql, sqlite
dsn: '%env(GOAT_DATABASE_URL)%'
use Goat\Runner\RunnerInterface; // core library class — confirm the name in vendor/
class ReportController
{
public function __invoke(RunnerInterface $runner)
{
$result = $runner->execute(
$runner->getQueryBuilder()
->select('article', 'a')
->columns(['a.id', 'a.title'])
->condition('a.published', true)
->orderBy('a.created_at', 'DESC')
->range(0, 20)
);
return $this->render('report.html.twig', ['rows' => $result->fetchAll()]);
}
}
If autowiring fails, fall back to the explicit alias from the services file (commonly goat_query.runner, goat_query.runner.default, or goat_query).
Inject the runner, not raw PDO. The runner owns query compilation and parameter binding; injecting it keeps driver-specific quoting out of your services and makes the SQLite test configuration a one-line swap.
Build a query object, reuse it. Goat queries are immutable-ish value objects. A common pattern is a small factory service that returns pre-scoped queries:
final class ArticleQueries
{
public function __construct(private RunnerInterface $runner) {}
public function published(): SelectQuery
{
return $this->runner->getQueryBuilder()
->select('article', 'a')
->condition('a.published', true);
}
}
Callers then add ->range(), ->condition(), or extra joins without duplicating the base predicate.
Named parameters over inline values. Use Expression/placeholder values ($builder->createPlaceholder('id')) rather than string concatenation — this is the only thing that keeps the builder driver-neutral and safe.
Transactions. Wrap multi-statement writes in the runner's transaction API ($runner->transactional(fn () => ...)), and note that nesting behaviour is driver-dependent; keep transactions at one layer (application service), never in both the controller and the repository.
Mapping results. Goat returns raw rows. Either hydrate manually in a small mapper or use a hydrator if the installed version exposes one — check vendor/makinacorpus/goat-query/src/ for Hydrator/ResultIterator before writing your own.
Alongside Doctrine, not instead of it. The pragmatic integration is: Doctrine ORM for entity persistence, Goat for reporting/analytics/bulk reads where you want hand-tuned SQL and no identity map. Both can share the same database credentials.
Testing. Point the test environment at SQLite (or a dedicated schema) in config/packages/test/goat_query.yaml, and assert against query objects where possible — inspecting generated SQL ($query->toString() or the runner's formatter) is cheaper than hitting the DB for every case.
Console/debug integration. If the bundle registers a data collector, enable it only in dev; otherwise instrument your own wrapper around the runner to log SQL and timing.
License is the first gate. GPL-2.0 is copyleft: linking it into a proprietary application is a legal question, not a technical one. Get sign-off before adopting, or treat it as suitable only for GPL-compatible/OSS projects.
Zero adoption means zero safety net. No Stack Overflow answers, no third-party tutorials, no community bug reports. Pin an exact version in composer.json, read CHANGELOG.md before every bump, and expect pre-1.0 API churn in the underlying query builder.
Symfony version drift. Bundles at this maturity level often lag: an old-style services.xml won't autowire cleanly on modern Symfony, and services may be private by default. If you get "cannot autowire … no such service", add an explicit services.yaml alias to the service ID you found in the extension.
Config key mismatch is the most common failure. The bundle name (goat_query), root config key, and service prefix are not guaranteed to match the Composer package name. Read Configuration.php before writing YAML — a wrong root key produces a silently ignored config block and a "service not found" much later.
Placeholder/driver quirks. The same query object can compile differently per driver; positional vs named parameter handling and LIMIT/OFFSET syntax differ. If a query works on Postgres and fails on SQLite in CI, compare the compiled SQL first.
Debugging. Dump the compiled query and its bound parameters before executing — SQL errors from this stack usually surface as driver-level PDO exceptions with no query context. A thin decorator around the runner that logs sql + params on exception pays for itself immediately.
Extension points worth knowing. Custom SQL expressions/types, custom hydrators, and additional named connections are the sanctioned extension seams. Register them through the bundle config rather than subclassing the runner, which is the part most likely to break between versions.
Escape hatch. If the bundle's DI wiring fights you, skip it: instantiate the core goat-query runner yourself in a small factory service and register that. The bundle is thin glue, and you lose little by replacing the glue with 20 lines you control — which is also the safest long-term position given the project's bus factor.
How can I help you explore Laravel packages today?