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

countable-values.phpphp
<?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 Countable provide their size through Countable::count().

  • ArrayObject is 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, but count() 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

temporary-diagnostic.phpphp
<?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 as null, array, or a class name.

  • is_countable() is true only for arrays and Countable objects.

  • 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

AdRepository.phpphp
<?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 array return type makes the runtime contract explicit.

  • The list<Ad> PHPDoc gives static analyzers the element shape and integer-key expectation.

  • ?? [] converts only null or an undefined operand to an empty array; it does not turn arbitrary scalars into collections.

  • If queryAds() can return false on 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

ad-slot.phpphp
<?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.

  • foreach naturally 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 else branch 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

pagination.phpphp
<?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 match expression 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 Countable implementation unless its behavior is known.

When the input legitimately has several forms

safe-count.phpphp
<?php
 
function safeCount(mixed $value): int
{
    if (!is_countable($value)) {
        return 0;
    }
 
    return count($value);
}

is_countable() is a policy gate, not a cure

  • mixed truthfully declares that callers may supply any runtime type.

  • is_countable() is available from PHP 7.3 and accepts arrays or Countable objects.

  • Returning zero for every other type is a business decision; it may hide corruption if those types are unexpected.

  • At strict internal boundaries, throw InvalidArgumentException or 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

empty-semantics.phpphp
<?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

nullable-input.phpphp
<?php
 
/** @param list<Ad>|null $ads */
function visibleAdCount(?array $ads): int
{
    return count($ads ?? []);
}

Null and empty may or may not be equivalent

  • ?array permits exactly an array or null.

  • The null-coalescing operator converts null to 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 mixed everywhere.

Countable objects need honest implementations

AdCollection.phpphp
<?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

  • Countable requires a public count(): int method.

  • IteratorAggregate is separately implemented so the collection can be used with foreach.

  • 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 false for query failure and [] for no rows; do not merge those outcomes.

  • JSON decoding can return null for the JSON literal null and 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 calling count() can have different query/memory costs than a database COUNT(*).

  • Template variables can be absent because a controller skipped a context key; initialize required collections in every path.

Catch the bug with static analysis

example.phpphp
<?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

tests/AdSummaryTest.phpphp
<?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 Ad constructor shown is domain-specific test setup and should match the real project API.

PHP upgrade workflow

  1. Inventory production extensions, framework/CMS/plugin versions, and PHP constraints.

  2. Run tests and static analysis on the current runtime with warnings treated as actionable.

  3. Search for count() calls whose input type is unknown or nullable.

  4. Upgrade dependencies and replace deprecated behavior before changing production.

  5. Exercise real integration payloads and empty/error responses in staging.

  6. Run the test matrix on the target PHP series and inspect logs for warnings, deprecations, and TypeErrors.

  7. 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|Countable or use is_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