The most important clue in this error is not count()—it is the value that reached it. An empty array does not cause the warning. count([]) returns 0. The warning appears when a path that was assumed to produce an array or Countable object instead produces something like null, false, a string, or an integer.
What count() accepts
<?php
declare(strict_types=1);
var_dump(count([])); // int(0)
var_dump(count(["a", "b"])); // int(2)
var_dump(count(new ArrayObject([1]))); // int(1)
// PHP 8+: TypeError: count(): Argument #1 ($value) must be of type
// Countable|array, null given
// count(null);The accepted contract is narrow
Arrays are countable even when empty.
Objects implementing
Countableprovide their size throughCountable::count().ArrayObjectis a standard-library example of a countable object.null, booleans, numbers, strings, and ordinary objects are not valid merely because an older PHP version returned a fallback value.declare(strict_types=1)improves scalar parameter coercion behavior, butcount()enforces its own runtime union type regardless.
First fix the original diagnosis
The historical code blamed an “empty array.” That is incorrect. $ads was almost certainly not an array on the failing pages. It may have been undefined, null, false from an API/database helper, or another type. The repair begins by observing the value at the boundary that produced it.
Inspect type without leaking production data
<?php
error_log(json_encode([
"component" => "ad-slot",
"type" => get_debug_type($ads),
"is_countable" => is_countable($ads),
], JSON_THROW_ON_ERROR));Log shape, not sensitive content
get_debug_type()returns useful names such asnull,array, or a class name.is_countable()is true only for arrays andCountableobjects.Structured context makes intermittent route-specific failures searchable.
Do not dump ad payloads, user records, tokens, or full request bodies into logs.
Remove or reduce temporary diagnostics after the source path is understood.
Best fix: make the producer always return an array
<?php
declare(strict_types=1);
final class AdRepository
{
/** @return list<Ad> */
public function forPage(int $pageId): array
{
$rows = $this->queryAds($pageId);
return $rows ?? [];
}
}Normalize at the boundary once
The
arrayreturn type makes the runtime contract explicit.The
list<Ad>PHPDoc gives static analyzers the element shape and integer-key expectation.?? []converts onlynullor an undefined operand to an empty array; it does not turn arbitrary scalars into collections.If
queryAds()can returnfalseon failure, handle that failure explicitly rather than calling it “no ads.”Downstream templates can now use
count(), truthiness, or iteration without repeating defensive type checks.
Then keep the template condition simple
<?php if ($ads !== []): ?>
<?php foreach ($ads as $ad): ?>
<?= htmlspecialchars($ad->title, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?>
<?php endforeach; ?>
<?php else: ?>
<p>No ads are available.</p>
<?php endif; ?>The condition says exactly what it means
With an array contract, comparison to
[]distinguishes an empty list directly.foreachnaturally handles each element and would also be safe on an empty array.htmlspecialchars()encodes untrusted text for an HTML text context; other contexts need context-specific escaping.The
elsebranch is optional when an empty state should render nothing.Short echo tags
<?= ... ?>are always available in supported PHP versions; avoid legacy bare<?short tags.
Use count() when the number matters
<?php
/** @param list<Ad> $ads */
function summarizeAds(array $ads): string
{
$total = count($ads);
return match ($total) {
0 => "No ads",
1 => "One ad",
default => "$total ads",
};
}Count is appropriate after type validation
The parameter type ensures the function receives an array.
count()returns the number of top-level elements by default.A
matchexpression uses strict comparison and must cover the provided value or include a default arm.Pluralization can become locale-specific; production UI may belong in an internationalization layer.
Do not repeatedly count an expensive custom
Countableimplementation unless its behavior is known.
When the input legitimately has several forms
<?php
function safeCount(mixed $value): int
{
if (!is_countable($value)) {
return 0;
}
return count($value);
}is_countable() is a policy gate, not a cure
mixedtruthfully declares that callers may supply any runtime type.is_countable()is available from PHP 7.3 and accepts arrays orCountableobjects.Returning zero for every other type is a business decision; it may hide corruption if those types are unexpected.
At strict internal boundaries, throw
InvalidArgumentExceptionor narrow the parameter type instead.Use this pattern at genuinely polymorphic integration edges, and record unexpected types for diagnosis.
Why empty() is not an equivalent replacement
<?php
$values = [null, false, 0, 0.0, "", "0", [], [0]];
foreach ($values as $value) {
printf("%-8s empty=%s countable=%s\n",
get_debug_type($value),
empty($value) ? "yes" : "no",
is_countable($value) ? "yes" : "no",
);
}Emptiness combines several unrelated meanings
empty()is true for undefined variables,null,false, numeric zero, empty strings, the string"0", and empty arrays.It does not warn for an undefined variable, which can conceal a misspelling or missing assignment.
It answers “is this value falsey under PHP empty semantics?”, not “is this a collection with zero members?”
Use
empty()only when all of those falsey states intentionally mean the same thing.For a required collection, enforce the type and compare/count the collection instead.
Nullable collection: model it explicitly
<?php
/** @param list<Ad>|null $ads */
function visibleAdCount(?array $ads): int
{
return count($ads ?? []);
}Null and empty may or may not be equivalent
?arraypermits exactly an array ornull.The null-coalescing operator converts
nullto an empty array before counting.Use this only when “not loaded/not provided” and “loaded with zero rows” have the same outcome.
If those states drive different UI or retry behavior, preserve them and branch explicitly.
A narrower type is more informative than accepting
mixedeverywhere.
Countable objects need honest implementations
<?php
declare(strict_types=1);
/** @implements IteratorAggregate<int, Ad> */
final class AdCollection implements Countable, IteratorAggregate
{
/** @param list<Ad> $items */
public function __construct(private array $items) {}
public function count(): int
{
return count($this->items);
}
public function getIterator(): Traversable
{
yield from $this->items;
}
}Countable does not automatically mean iterable
Countablerequires a publiccount(): intmethod.IteratorAggregateis separately implemented so the collection can be used withforeach.The count should represent the collection’s documented membership, not an unrelated database total.
A lazy database-backed count might execute a query; document cost and avoid surprising repeated I/O.
Immutable or encapsulated collection objects can protect element invariants better than unstructured arrays.
Framework and database results need contract checks
Database APIs may return
falsefor query failure and[]for no rows; do not merge those outcomes.JSON decoding can return
nullfor the JSON literalnulland may fail for malformed input; use exceptions and validate shape.HTTP clients may return response objects, decoded arrays, or errors depending on configuration.
ORM collections may implement
Countable, but callingcount()can have different query/memory costs than a databaseCOUNT(*).Template variables can be absent because a controller skipped a context key; initialize required collections in every path.
Catch the bug with static analysis
<?php
/** @param list<Ad> $ads */
function renderAds(array $ads): void
{
if (count($ads) === 0) {
return;
}
// Render known Ad values...
}Types move the failure closer to its source
Runtime parameter types reject invalid callers at the function boundary.
PHPDoc generics let PHPStan or Psalm check the element type beyond native
array.Analyze the project at a strictness level the codebase can sustain, then ratchet it upward.
Use baseline files as temporary migration tools, not permanent permission for new violations.
Static analysis complements tests; external data still needs runtime validation.
Test the meaningful input states
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class AdSummaryTest extends TestCase
{
public static function cases(): iterable
{
yield "empty" => [[], "No ads"];
yield "one" => [[new Ad("A")], "One ad"];
yield "many" => [[new Ad("A"), new Ad("B")], "2 ads"];
}
#[DataProvider("cases")]
public function testSummary(array $ads, string $expected): void
{
self::assertSame($expected, summarizeAds($ads));
}
}The test encodes collection behavior
The provider covers zero, one, and multiple members.
The array parameter prevents a null/scalar case from being treated as valid input.
Add separate boundary tests proving malformed API/database payloads are rejected or normalized as designed.
Run the suite on every supported PHP version before upgrading production.
The
Adconstructor shown is domain-specific test setup and should match the real project API.
PHP upgrade workflow
Inventory production extensions, framework/CMS/plugin versions, and PHP constraints.
Run tests and static analysis on the current runtime with warnings treated as actionable.
Search for
count()calls whose input type is unknown or nullable.Upgrade dependencies and replace deprecated behavior before changing production.
Exercise real integration payloads and empty/error responses in staging.
Run the test matrix on the target PHP series and inspect logs for warnings, deprecations, and TypeErrors.
Deploy gradually with rollback, error monitoring, and version-consistent workers/CLI jobs.
Verify the repaired contract after release
Monitor count-related warnings and TypeErrors across web, queue, cron, and CLI processes.
Record unexpected input types without logging sensitive payloads.
Compare empty-state and upstream-error metrics so normalization cannot hide outages.
Confirm every worker pool runs the intended PHP build and dependency lock.
Remove temporary compatibility guards once callers obey the narrowed contract.
Common “fixes” and their hidden cost
Replace every count with empty: collapses null, false, zero,
"0", empty string, undefined, and empty array.Cast everything to array:
(array) "ad"becomes a one-element array, potentially legitimizing bad input.Use count($value ?? []): good only when the declared alternate state is null and null means empty.
Use is_countable everywhere: prevents the call but can spread uncertainty instead of fixing the producer.
Catch TypeError and return zero: converts a programming/data error into plausible but false business data.
Downgrade PHP: restores older symptoms and loses security/support progress without fixing the contract.
Disable warnings: removes early evidence until PHP 8 or another code path fails harder.
Decision checklist
Is the value always conceptually a collection? Return an array or collection object from its producer.
Can null legitimately mean empty? Normalize null once at the boundary and document it.
Can several types legitimately be countable? Accept
array|Countableor useis_countable()with explicit fallback policy.Is non-countable input invalid? Narrow the type or throw a domain exception.
Do zero, false, empty string, and empty array mean the same thing? Only then consider
empty().Does counting trigger I/O? Use the API that expresses the desired database or remote count efficiently.
Are warnings visible in CI/staging? Make the upgrade surface observable before PHP 8 turns it into a runtime exception.
Official PHP references
count() defines accepted values, return behavior, and the PHP 7.2/PHP 8 changes.
is_countable() checks arrays and
Countablevalues.Countable defines the object interface used by
count().empty() documents its broad falsey and undefined-variable semantics.
PHP 7.2 backward-incompatible changes records the warning for invalid count arguments.
PHP type declarations explains native parameter and return contracts.
Comments and corrections