pelfox/laravel-bigquery
Use Laravel's Query Builder and Eloquent with Google BigQuery. Configure a BigQuery connection or use the facade, query tables/datasets, and map BigQuery types with dedicated Eloquent casts, including repeated fields and structs.
Install and wire up (≈30 minutes to first query):
composer require pelfox/laravel-bigquery
Add a secondary connection in config/database.php — this package registers a database driver, so it slots into Laravel's existing connection manager rather than introducing a new SDK:
'bigquery' => [
'driver' => 'bigquery',
'database' => '',
'prefix' => '',
'dataset' => env('BQ_DATASET'), // required
'keyFilePath' => env('BQ_KEY_FILE_PATH'), // required
],
Then confirm reachability before writing any application code:
DB::connection('bigquery')->table('your_table')->limit(5)->get();
If that returns rows, the hard part is done. Everything after this is a design decision, not an integration problem.
Where to look first, in this order:
Pelfox\LaravelBigQuery\Eloquent\Casts namespace — this is the actual value of the package. Without it you are hand-coercing NUMERIC, BIGNUMERIC, BYTES, TIMESTAMP, STRUCT, JSON, and repeated fields, and that is exactly where precision and timezone bugs breed. Read the cast table in the README before you write a single model.public $incrementing = false; and public $timestamps = false;. Internalise that before you reach for save().1.5.1 fixes escaping of strings with line breaks and repeated-cast coercion; 1.5.2 adds informative SQL errors and Escape::json(). That history is a signal about where to focus your own testing.First use case to build: a read-only internal report — a filtered, aggregated, LIMIT-bounded query behind an admin screen or an artisan command. Do not start with a write path, a model with relationships, or anything on a user-facing request.
Pick the Query Builder as your default, Eloquent as the exception. BigQuery does not honour transactions, auto-incrementing keys, timestamps, model events, or read-after-write consistency. Every one of those is an Eloquent default. The Query Builder imports none of those assumptions and makes it visually obvious at the call site that this is an analytical read:
$rows = DB::connection('bigquery')
->table('events')
->selectRaw('DATE(occurred_at) as day, COUNT(*) as total')
->where('occurred_at', '>=', $since)
->groupBy('day')
->get();
Reach for an Eloquent model only when you genuinely want a read model with casts and accessors attached.
Isolate all package-specific code behind a boundary. This is the single highest-value architectural decision here. Put every query, cast definition, and struct schema in one place — app/Queries/BigQuery/ or a repository layer — so that "replace this package" is a swap, not a refactor:
app/Queries/BigQuery/
├── DailyEventCountsQuery.php
├── RevenueByCohortQuery.php
└── Concerns/ # shared casts, struct schemas
Given four stars, zero dependents, and a single maintainer, assume you will eventually own a fork. A clean seam is what makes that cheap.
Dataset routing: prefer the connection default, override deliberately. The connection's dataset key handles the common case. Dataset-qualified names are available for cross-dataset reads:
DB::connection('bigquery')->table('other_dataset.table')->get();
// Eloquent
protected $table = 'other_dataset.table';
Use qualification sparingly. It makes environment routing implicit — a typo silently queries the wrong dataset, and a hard-coded dataset name in a $table property is an environment leak waiting to happen. Where you must qualify, build the name from config, not a literal.
Map every column through a cast, including repeated and struct fields. Don't rely on default coercion:
protected $casts = [
'amount' => AsNumeric::class, // never a plain float
'ids' => AsInteger::class . ':1', // ':1' = repeated mode
'payload' => AsJson::class,
'meta' => AsStruct::class . ':0,metaSchema', // ':0' + schema method
];
public function metaSchema(): array
{
return [
'source' => StringType::class,
'count' => IntegerType::class,
'tags' => [StringType::class], // repeated within struct
];
}
The struct schema must mirror the BigQuery schema exactly — nested arrays, repeated markers, and all — or inserts will error. Treat the schema method as a contract and pin it with a test that inserts and reads back.
Verify casts by round-tripping, not by reading the README. For NUMERIC, BIGNUMERIC, TIMESTAMP with non-UTC data, BYTES, JSON, repeated scalars, and nested structs: write a row, read it back, assert equality. Silent precision loss and timezone drift are the highest-severity failure modes this package has, because they don't throw — they surface in a report three weeks later.
Use Escape::json() when hand-building JSON fragments. It exists specifically because JSON literal escaping is easy to get subtly wrong:
use Pelfox\LaravelBigQuery\Escape;
->whereRaw('json_field = ' . Escape::json($value))
Prefer bindings wherever the builder supports them; treat whereRaw as a last resort that requires review.
Keep credentials out of the repo and out of logs. keyFilePath is a long-lived static secret on disk. Source it from your secrets manager at deploy time via env(), never commit the JSON, and make sure it isn't echoed in CI. Write a rotation runbook — this is a deliberate decision, not a config line copied from a README.
SELECT * is a billing event, not a convenience. BigQuery charges by bytes scanned, and this package gives you no dry-run, no cost estimate, and no default LIMIT. An ORM that makes unbounded queries feel free is a cost hazard. Enforce guardrails at the project level — quota limits, maximum_bytes_billed, partition filters — outside this package, because you cannot enforce them inside it. Always filter on partitioned/clustered columns, and put a LIMIT on anything exploratory.
Escaping has a bug history — review every user-supplied value in a where. Releases 1.5.1 and 1.5.2 both fixed value-escaping defects (line-broken strings, repeated-cast coercion to string). That is a security-relevant signal for a query builder. Prefer bound parameters, and get an independent review on any path where request data reaches a where clause.
Never assume save() round-trips. Set public $incrementing = false; and public $timestamps = false; on every model, and don't rely on model events firing or on a write being immediately visible. update/delete without a where used to error before 1.0.4 — the fix means an unscoped mass update is now possible. Keep an eye on it.
Model events and relationships are traps. HasUuids works (it's in the README example) because it generates the key client-side. belongsTo/hasMany assume local joins with predictable latency; BigQuery joins are expensive and slow. Do the join in a single query with the Query Builder rather than traversing relationships.
Don't trust the version number alone. v3.0.0 shipped with no changelog notes beyond a compare link, and releases track Laravel majors on the maintainer's schedule rather than yours. Before upgrading, read the diff. Pin the version in composer.json so a framework upgrade doesn't silently drag the BigQuery driver with it.
Pagination isn't tuned for BigQuery. paginate() will produce correct-but-expensive OFFSET-style paging over large result sets. For anything sizeable, cache aggregates into a local table or push results to a materialised summary and page over that.
Debugging: lean on the improved SQL errors from 1.5.2. When a query fails, the package now surfaces a more informative error than the raw client. If you get a schema-mismatch error on insert, the cause is almost always a struct schema method that doesn't match the actual BigQuery schema — diff them field by field, including repetition.
Two things to decide before this reaches production:
google/cloud-bigquery. Because you kept everything behind app/Queries/BigQuery/, that switch is a swap, not a rewrite.And ask whether PHP is the right place at all. For recurring reports, a scheduled job writing to a cache table beats a web request hitting BigQuery. This package is excellent for interactive, low-frequency internal reads, and a poor fit for anything on a user-facing request path or with a sub-second latency expectation.
How can I help you explore Laravel packages today?