laravel/framework · error · InvalidArgumentException
A subquery must be a query builder instance, a Closure, or a
Error message
A subquery must be a query builder instance, a Closure, or a string.
What it means
Thrown by Query\Builder::parseSub when a value passed to a subquery-accepting API is neither a Query\Builder, an Eloquent\Builder, a Relation, nor a string. parseSub is invoked (via createSub) by methods like whereExists, joinSub, fromSub, orderBy subquery, and groupBy subquery. Despite the message naming Closure, Closures are resolved earlier in createSub; reaching this branch means the caller passed an array, null, int, object, or resource where a query was expected.
Source
Thrown at src/Illuminate/Database/Query/Builder.php:436
/**
* Parse the subquery into SQL and bindings.
*
* @param mixed $query
* @return array
*
* @throws \InvalidArgumentException
*/
protected function parseSub($query)
{
if ($query instanceof self || $query instanceof EloquentBuilder || $query instanceof Relation) {
$query = $this->prependDatabaseNameIfCrossDatabaseQuery($query);
return [$query->toSql(), $query->getBindings()];
} elseif (is_string($query)) {
return [$query, []];
} else {
throw new InvalidArgumentException(
'A subquery must be a query builder instance, a Closure, or a string.'
);
}
}
/**
* Prepend the database name if the given query is on another database.
*
* @param mixed $query
* @return mixed
*/
protected function prependDatabaseNameIfCrossDatabaseQuery($query)
{
if ($query->getConnection()->getDatabaseName() !==
$this->getConnection()->getDatabaseName()) {
$databaseName = $query->getConnection()->getDatabaseName();
if (! str_starts_with($query->from, $databaseName) && ! str_contains($query->from, '.')) {View on GitHub (pinned to bd6b5437e6)
Solutions
- Wrap the argument in a closure: `whereExists(fn ($q) => $q->select(...)->from(...))`.
- Pass the query builder instance directly: `User::query()->where('active', 1)` instead of `User::all()`.
- If you intend raw SQL, pass a string: `fromSub('select ...', 'sub')`.
- Guard nullable relations: `$relation ?->getQuery() ?? User::query()->whereRaw('1=0')`.
Example fix
// before
User::whereExists([$someIds])->get();
// => A subquery must be a query builder instance, a Closure, or a string.
// after
User::whereExists(function ($q) use ($someIds) {
$q->select(DB::raw(1))->from('orders')->whereIn('user_id', $someIds);
})->get(); Defensive patterns
Strategy: type-guard
Validate before calling
if (! is_string($arg)
&& ! $arg instanceof \Illuminate\Database\Query\Builder
&& ! $arg instanceof \Illuminate\Database\Eloquent\Builder
&& ! $arg instanceof \Illuminate\Database\Eloquent\Relations\Relation
&& ! $arg instanceof \Closure) {
throw new InvalidArgumentException('Subquery argument must be a builder, relation, closure, or SQL string.');
} Type guard
use Illuminate\Database\Query\Builder as QB;
use Illuminate\Database\Eloquent\Builder as EB;
use Illuminate\Database\Eloquent\Relations\Relation;
function isSubqueryable(mixed $q): bool
{
return $q instanceof QB
|| $q instanceof EB
|| $q instanceof Relation
|| $q instanceof \Closure
|| is_string($q);
} Try / catch
try {
$query->whereExists($maybeSub);
} catch (\InvalidArgumentException $e) {
if (str_contains($e->getMessage(), 'subquery must be a query builder')) {
// fall back to a no-op subquery or rebuild with a closure
}
throw $e;
} Prevention
- Always wrap subquery SQL in a closure passed to whereExists/whereHas unless you hold an explicit builder.
- Null-check optional relations before feeding them to subquery APIs.
- Use static analysis (PHPStan) with the builder generics to catch wrong types at CI time.
When it happens
Trigger: Passing a raw array to `whereExists([...])` instead of a closure/builder. Calling `orderBySub(['col'])`. Handing `null` to `fromSub()` after an optional-relation lookup returns null. Passing an Eloquent Collection instead of a query builder. Passing a model instance rather than `Model::query()`.
Common situations: Refactoring a `whereIn` to a `whereExists` and forgetting the closure wrapper; nullable relation resolution feeding directly into a subquery API; dynamic data (e.g. request input) reaching a subquery parameter without coercion to a builder or SQL string.
Related errors
- Nested arrays may not be passed to whereIn method.
- The number of columns must match the number of values
- Order direction must be a SortDirection, "asc" or "desc".
- Timeout must be greater than zero.
- The unique columns must not be empty.
AI-assisted analysis of laravel/framework@bd6b5437e6 (2026-08-06).
Data as JSON: /data/errors/fba3fddb09f1eccf.json.
Report an issue: GitHub.