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

  1. Wrap the argument in a closure: `whereExists(fn ($q) => $q->select(...)->from(...))`.
  2. Pass the query builder instance directly: `User::query()->where('active', 1)` instead of `User::all()`.
  3. If you intend raw SQL, pass a string: `fromSub('select ...', 'sub')`.
  4. 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

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


AI-assisted analysis of laravel/framework@bd6b5437e6 (2026-08-06). Data as JSON: /data/errors/fba3fddb09f1eccf.json. Report an issue: GitHub.