pixelfederation/druid-php
A PHP client for Apache Druid, the real-time analytics database. Build and run native JSON queries against Druid datasources through a fluent interface, letting your PHP backend fetch analytical results without hand-writing raw HTTP requests.
pixelfederation/druid-php is a thin, fluent PHP wrapper around Apache Druid's native JSON query API. It gives you query objects (TimeseriesQuery, TopNQuery, GroupByQuery, SelectQuery, SearchQuery, ScanQuery, TimeBoundaryQuery), matching builders under Druid\QueryBuilders\*, plus aggregations and filters, and it POSTs the assembled JSON to the broker, returning decoded arrays. There is no ORM, no connection pool, and no schema layer — what you build maps 1:1 onto what Druid's /druid/v2/ endpoint accepts.
Install:
composer require pixelfederation/druid-php
Before you write a line of code, open the package's composer.json and check the require block. This is a 2017-era library and it almost certainly pins guzzlehttp/guzzle:^6; on a modern Laravel app (Guzzle 7) composer require will fail with a version conflict. Resolve that first — either fork/vendor the package with a loosened constraint, or accept it as a reference implementation and hand-roll the JSON. Do not discover this halfway through a feature.
Smoke-test with the simplest possible query — one datasource, one interval, one count — against the broker on port 8082:
use Druid\Druid;
use Druid\Client;
use Druid\Queries\TimeseriesQuery;
use Druid\Aggregations\CountAggregator;
$druid = new Druid(new Client('http://druid-broker:8082'));
$query = (new TimeseriesQuery())
->setDataSource('pageviews')
->setIntervals(['2024-01-01T00:00:00.000Z/2024-01-02T00:00:00.000Z'])
->setGranularity('all')
->addAggregation(new CountAggregator('count'));
$rows = $druid->query($query); // [['timestamp' => ..., 'result' => ['count' => 12345]]]
Where to look first: src/Queries/ for the six query types, src/QueryBuilders/ for the fluent entry points, src/Aggregations/ (CountAggregator, LongSumAggregator, DoubleSumAggregator, JavascriptAggregator, …) and src/Filters/ (SelectorFilter, RegexpFilter, InFilter, BoundFilter, …). Keep Druid's own native-query documentation open beside it — the class names mirror the JSON keys almost exactly, which is the fastest way to figure out what a builder method is called.
Bind it once, configure it from config. Don't new Druid(...) in controllers. Register a singleton in a service provider, pull the broker URL from config/druid.php (env-driven), and construct the underlying Guzzle client there so you can set timeout, connect_timeout, and retry middleware in one place:
$this->app->singleton(Druid::class, function ($app) {
$http = new \GuzzleHttp\Client([
'base_uri' => config('druid.broker'),
'timeout' => 10,
]);
return new Druid(new Client($http));
});
One query object per report/panel. The biggest maintainability win is treating each dashboard panel or report as a named class — PageviewsByCountryQuery, DailyActiveUsersQuery — that returns a configured query object. The fluent builders make this tempting to inline in a controller; resist it, because Druid queries are the kind of thing that gets tweaked weekly and you want them testable and greppable.
Compose filters and post-aggregations rather than branching on strings. Use GroupByQueryBuilder with setDataSource(), addDimension(), addAggregation(), addFilter(), setGranularity(), addOrdering(), setLimit(), then ->build(). Post-aggregations (addPostAggregation()) and setHaving() are how you get ratios and thresholds — the aggregator name you pass is the key you reference downstream, so treat those strings as constants shared between aggregation and post-aggregation definitions.
Map results to DTOs immediately. Druid returns ragged arrays whose shape differs per query type — groupBy nests the metrics under an event key alongside timestamp and version, timeseries returns result, topN returns result as a list. Wrap the Druid::query() call in a small mapper so the rest of the app never touches raw Druid output.
Test by asserting the payload, not the network. Inject a Guzzle MockHandler into the Client, run a query, and assert on the decoded JSON body. That catches the real class of bug here: a misspelled granularity, an interval without the / separator, an aggregator missing its name.
Cache aggressively above the client. Druid has its own result cache (useCache / populateCache in the query context), but for a dashboard you'll still want Laravel's cache in front, keyed on a hash of the query array. json_encode($query) is a fine cache key.
Use it for scheduled/export jobs too. The same query objects work fine in an artisan command feeding a CSV export or a nightly rollup — nothing about the package is request-scoped.
ThetaSketch, Quantiles, DataSketches), joins, and most modern query context options. Budget for a fork in your vendor directory rather than expecting a PR to land. If your queries are complex, seriously consider skipping the library and POSTing plain JSON via Guzzle — you get SQL support and every new Druid feature for free./druid/v2/sql, either use the underlying Guzzle client directly or a separate client. Mixing the two is fine and often pragmatic — SQL for ad-hoc, the builder for fixed reports.start/end ISO-8601 strings joined by a slash, in UTC. 2024-01-01/2024-01-02 fails; 2024-01-01T00:00:00.000Z/2024-01-02T00:00:00.000Z works. Empty results are usually a malformed interval, not a missing datasource.'all', 'none', 'day', 'hour', etc., or a duration/period object. 'all' is what you want for a single scalar; forgetting it gives you one row per day and a query that "looks wrong."having clauses reference them by string. Renaming a count aggregator silently breaks the post-aggregation; Druid will reject the query with a 500 and a JSON body explaining which reference was unresolved.RequestException on any 4xx/5xx, and Druid's useful diagnostic lives in the response body, not the message. Always log $e->getResponse()->getBody()->getContents() — it names the offending field.groupBy and select are threshold-limited (setThreshold() / pagingSpec). Large cardinality dimension combinations will be silently truncated at the default threshold. Set it explicitly and don't assume you got everything.groupBy over a wide interval will hang a PHP-FPM worker until it dies. Always set a timeout, and set Druid's own query timeout context alongside it.toArray()/jsonSerialize() contract. Adding a new Druid aggregator the 2017 release doesn't know about is a 20-line class, which is why this package still works as a base despite being archived.How can I help you explore Laravel packages today?