byerikas/eloquent-bigquery
Bring Google BigQuery into Laravel's Eloquent ORM. Query BigQuery tables using familiar models, relationships, and query builder syntax, so you can work with large datasets without leaving your Laravel codebase. A lightweight bridge for analytics-style queries in PHP apps.
byerikas/eloquent-bigquery gives you an Eloquent-shaped, read-only query layer over Google BigQuery: you extend a base model, set a table, and chain where/select/groupBy/join the way you already do with Eloquent. It is a query builder and hydrator, not a full Eloquent driver — think "reporting models", not "CRUD models".
Minimal setup:
composer require byerikas/eloquent-bigquery
php artisan vendor:publish --tag=bigquery # publishes config/bigquery.php
// config/bigquery.php
return [
'project_id' => env('BIGQUERY_PROJECT_ID'),
'key_file_path' => env('BIGQUERY_KEY_FILE_PATH'), // or inline JSON content
'scopes' => [], // e.g. cloud-platform
];
use ByErikas\EloquentBigQuery\Model;
class OrderFact extends Model
{
protected $table = 'analytics.order_fact'; // dataset.table
}
$rows = OrderFact::query()
->select(['order_id', 'customer_id', 'total_amount'])
->where('order_date', '>=', '2024-01-01')
->orderBy('order_date', 'desc')
->limit(100)
->get();
Where to look first, in this order:
config/bigquery.php — auth is the #1 source of "it doesn't work".Model class — it defines $table, the query entrypoint, and how rows become models.QueryBuilder class — the single source of truth for which methods are actually supported. Do not assume parity with Eloquent; check the method list before you design around one.README / tests — the tests are the most accurate documentation of supported SQL shapes.First real use case: replace a nightly "export BigQuery to MySQL, then report in Laravel" pipeline with a directly-queried reporting model behind a thin repository. Keep it behind an interface so you can swap a fixture-backed fake in tests.
Repository-as-a-boundary. Never let BigQueryModel::query() leak into controllers or Blade. Wrap it:
interface RevenueReport
{
public function monthly(int $year): Collection;
}
class BigQueryRevenueReport implements RevenueReport
{
public function monthly(int $year): Collection
{
return RevenueFact::query()
->selectRaw('DATE_TRUNC(order_date, MONTH) AS month')
->selectRaw('SUM(total_amount) AS revenue')
->whereBetween('order_date', ["$year-01-01", "$year-12-31"])
->groupByRaw('month')
->orderBy('month')
->get();
}
}
This is also the seam that makes the package testable — the package has no BigQuery::fake() equivalent worth relying on, so the fake lives at your repository boundary, not the package's.
Select-list discipline as a hard rule. BigQuery bills by bytes scanned. Make ->select([...]) mandatory in code review; a bare ->get() or select('*') on a wide fact table is how you get a surprise invoice. Pair every query with a partition-column filter (order_date, _PARTITIONTIME) so the scan is pruned.
Aggregate in BigQuery, not in PHP. For dashboards, push groupBy/SUM/COUNT down and return pre-aggregated rows. Pulling 500k raw rows to Collection::groupBy in PHP is both slow and pointless — the columnar engine is right there.
Joins across datasets. Fully-qualified names work in ->join(); join on the same key types (BigQuery will not coerce INT64 to STRING for you). For star-schema reporting, denormalise into a view and query the view from a single model — fewer moving parts than four-table joins in the builder.
Raw escape hatch, with bindings. Anything the builder can't express (UNNEST, window functions, QUALIFY, ARRAY_AGG) goes through selectRaw/whereRaw/fromRaw with bindings, never string interpolation — user-supplied dates and IDs in a raw string is a correctness and injection problem.
Cache aggressively. BigQuery latency is 1–5s even for tiny queries. Wrap report methods in Cache::remember keyed on the filter arguments, and invalidate by TTL (15–60 min) rather than trying to detect upstream table changes. For anything over ~10s, move it to a queued job that writes a materialised result table your app reads normally.
Pagination with care. limit/offset works, but BigQuery still scans the full result set for a high offset. For deep pagination, use a keyset/WHERE id > ? pattern on an ordered column rather than page numbers.
Debugging loop. Build the query, dump the generated SQL, and paste it into the BigQuery console (or run a dry run against the client) before you run it from PHP. Iterating in the console is free; iterating through ->get() costs money and 20 seconds per attempt.
save(), update(), delete(), or insert() path here. If your assessment assumed "Eloquent model == writable model", it doesn't hold. Writes belong in google/cloud-bigquery directly or a separate ingestion path.NUMERIC / BIGNUMERIC come back as strings. The BigQuery client returns 64-bit-and-beyond numeric types as PHP strings to avoid precision loss, and this package does not silently cast them to float. Cast at the repository boundary ((float), or bcadd/Brick\Math for money). A float cast on a monetary NUMERIC is a silent rounding bug.INT64 and timestamps. PHP int is fine on 64-bit, but timestamps arrive as UTC; BigQuery has no stored timezone. Decide once (app timezone vs UTC) and convert in one place. Mixing whereDate('created_at', ...) with BigQuery's DATE() truncation is a classic off-by-one-day bug — prefer explicit whereRaw("DATE(created_at, 'Europe/London') = ?", [$date]) when the business day matters.whereHas, with, model events, mutators, $casts, or relationship methods work. Check the builder before designing around a method. Where it behaves like Eloquent, it also fails like Eloquent — silently ignored clauses are rare, but unsupported methods throw rather than degrade, which is the better failure mode.WHERE (only in HAVING/ORDER BY); HAVING requires GROUP BY; SELECT columns must be grouped or aggregated; no LIMIT inside a subquery without ORDER BY being meaningful. Errors surface late (at execution, not at ->toSql()), so the console-dry-run habit pays for itself.ORDER BY is not implicit. ->limit(10) without ->orderBy() returns an arbitrary ten rows and will look stable in dev, then change in prod.where('status', '!=', 'cancelled') silently drops NULL rows. If the column is nullable, write ->where(fn ($q) => $q->where('status', '!=', 'cancelled')->orWhereNull('status')) — the builder supports the closure form, but the SQL result is what BigQuery defines, which may still differ from your MySQL intuition.How can I help you explore Laravel packages today?