Model::whereNull
Constrains a query to rows where the given column or columns are SQL NULL.
public function whereNull(string|array|\Illuminate\Contracts\Database\Query\Expression $columns, string $boolean = 'and', bool $not = false): $thisParameters
| Parameter | Type | Description |
|---|---|---|
$columns | string|array|\Illuminate\Contracts\Database\Query\Expression | A column name, or an array of column names. An array adds one IS NULL clause per column, joined by the $boolean value. |
$booleanoptional | string= 'and' | How the clause joins the previous one — 'and' or 'or'. |
$notoptional | bool= false | When true, compiles to IS NOT NULL. This is how whereNotNull() is implemented. |
Return value
$thisThe same builder instance, so calls can be chained.
whereNull() verifies that the value of the given column is NULL, compiling to column is null. Its counterpart whereNotNull() is the same method with the $not flag set, producing column is not null. Both also have orWhereNull() and orWhereNotNull() variants.
Why it exists
In SQL, NULL is not equal to anything, including itself, so column = NULL never matches. IS NULL is the only correct test. Laravel is helpful here: passing null as the value to where() is rewritten internally to a whereNull() call, and a negating operator such as <> becomes IS NOT NULL. Calling whereNull() directly just makes the intent explicit.
What trips people up
NULL is not the empty string, and it is not zero. A varchar column that stores '' for a missing value is not found by whereNull(). Legacy schemas and some form handling default to empty strings, and the fix is usually to test both.
Passing an array adds one clause per column joined with and by default, so whereNull(['a', 'b']) requires both to be null. People expect "either".
On soft-deleting models the deleted_at is null clause is already added by the global scope — adding your own is redundant and confusing.
Example
<?php
use App\Models\User;
// Rows where the column is SQL NULL.
$users = User::whereNull('updated_at')->get();
// select * from users where updated_at is null
echo $users->count(), "\n";
// The counterpart.
$users = User::whereNotNull('updated_at')->get();
// select * from users where updated_at is not null
echo $users->count(), "\n";
// where() with a null value produces the same SQL.
$users = User::where('updated_at', null)->get();
// select * from users where updated_at is null
echo $users->count(), "\n";When to use it
- Find records a background job has not processed yet by testing an unset processed_at timestamp.
- List users who never completed email verification.
- Locate orphaned rows whose optional foreign key was never populated.
Frequently asked questions
Why doesn't where('column', null) work in Laravel?
It does — Laravel detects a null value inside where() and rewrites the clause to whereNull(), producing column is null rather than the literal column = NULL that would never match. A negating operator such as '<>' or '!=' produces IS NOT NULL instead. Calling whereNull() directly is still preferable because the intent is visible to the next reader.
Does whereNull() match empty strings or zero?
No. It compiles to SQL IS NULL, which is true only for a genuine NULL — an empty string, a zero, and the string '0' are all distinct values that it will not return. On schemas that store '' for missing text, combine the tests: where(fn ($q) => $q->whereNull('phone')->orWhere('phone', '')).
How do I check that either of two columns is null?
Group the checks in a closure and use orWhereNull for the second: where(function ($q) { $q->whereNull('bio')->orWhereNull('avatar_url'); }). Passing an array — whereNull(['bio', 'avatar_url']) — joins the clauses with AND, so it only matches rows where both columns are null. The closure also keeps the OR from escaping past your other conditions.
More examples
NULL is not an empty string
The first two counts partition the "blank" rows into two disjoint groups, and their sum is the third count. This is the query people report as broken: whereNull() alone under-reports on any schema that stores empty strings for optional text.
<?php
use App\Models\Contact;
// Only true SQL NULL - a row storing '' is NOT returned.
echo Contact::whereNull('phone')->count(), "\n";
// Only the empty strings.
echo Contact::where('phone', '')->count(), "\n";
// Both, which is usually what "no phone number" means.
$missing = Contact::where(function ($query) {
$query->whereNull('phone')
->orWhere('phone', '');
})->get();
echo $missing->count(), "\n";
// Rows that genuinely have a number.
echo Contact::whereNotNull('phone')
->where('phone', '<>', '')
->count(), "\n";Arrays join with AND, not OR
The array form is an AND of IS NULL checks, so it finds only completely blank profiles. The grouped version finds profiles missing either field, and the parentheses keep the active filter applying to both branches.
<?php
use App\Models\Profile;
// BOTH columns must be null.
Profile::whereNull(['bio', 'avatar_url'])->count();
// where bio is null and avatar_url is null
// EITHER column null - group it so the OR stays contained.
$incomplete = Profile::where('active', 1)
->where(function ($query) {
$query->whereNull('bio')
->orWhereNull('avatar_url');
})
->get();
// where active = ? and (bio is null or avatar_url is null)
echo $incomplete->count(), "\n";Finding unprocessed work
The pending query is the standard claim pattern for a worker: a null timestamp marks untouched rows. The stale query needs whereNotNull explicitly because a NULL processed_at would not satisfy the < comparison either — NULL comparisons are never true, so the rows would be dropped silently rather than counted as stale.
<?php
use App\Models\Upload;
use Illuminate\Support\Carbon;
Carbon::setTestNow('2026-03-01 12:00:00');
// Never processed at all.
$pending = Upload::whereNull('processed_at')
->orderBy('created_at')
->limit(100)
->get();
echo $pending->count(), "\n";
// Processed, but stale - note the two clauses are not interchangeable.
$stale = Upload::whereNotNull('processed_at')
->where('processed_at', '<', Carbon::now()->subDays(7))
->get();
echo $stale->count(), "\n";
// Redundant on a SoftDeletes model - the global scope already adds this.
// Upload::whereNull('deleted_at')->get();
Discussion