skip to content

In PHP, what do the Countable and ArrayAccess interfaces let an object do, and where does the array illusion break down?

level: middleimportance: should knowfreq 40%

answer

  1. count($obj) calls count()
  2. four offset methods
  3. $obj[] passes a null offset
  4. empty() calls offsetExists then offsetGet
  5. Indirect modification of overloaded element

basics

~20 s

Countable makes count($obj) call the object's count() method. ArrayAccess maps $obj[$k] reads, writes, isset and unset to offsetGet, offsetSet, offsetExists and offsetUnset. The object is still not an array: array_* functions reject it and nested writes need offsetGet by reference.

solid answer

~40 s

`Countable` has one method, `count(): int`; `count($seats)` on an implementing object returns its result, and on any other object `count()` throws a `TypeError` since PHP 8.0. `ArrayAccess` has four methods: `offsetExists()` for `isset()`, `offsetGet()` for reads, `offsetSet()` for writes (with a `null` offset for `$obj[] = $v`) and `offsetUnset()` for `unset()`. `empty($obj['A1'])` calls `offsetExists()` and then `offsetGet()` only if that returned true. The illusion stops at the syntax: `array_key_exists()`, `in_array()` and the other array functions still require a real array, `foreach` needs a separate iteration interface, and an indirect write such as `$obj['A1'][] = 'x'` goes through `offsetGet()`, so unless it returns by reference PHP emits `Indirect modification of overloaded element` and the write is lost.

code

php · 40 lines
php
<?php
declare(strict_types=1);

final class SeatMap implements ArrayAccess, Countable
{
    /** @var array<string, string> */
    private array $holders = [];

    public function offsetExists(mixed $offset): bool
    {
        return isset($this->holders[$offset]);
    }

    public function offsetGet(mixed $offset): mixed
    {
        return $this->holders[$offset] ?? null;
    }

    public function offsetSet(mixed $offset, mixed $value): void
    {
        if (!is_string($offset) || !preg_match('/^[A-Z]\d{1,2}$/', $offset)) {
            throw new InvalidArgumentException('Seat code required');
        }
        $this->holders[$offset] = $value;
    }

    public function offsetUnset(mixed $offset): void
    {
        unset($this->holders[$offset]);
    }

    public function count(): int
    {
        return count($this->holders);
    }
}

$seats = new SeatMap();
$seats['A12'] = 'Ada';
echo count($seats), ' ', isset($seats['A12']) ? 'taken' : 'free';

go deeper

for a junior

Recall the method names: count() for Countable, and offsetGet, offsetSet, offsetExists and offsetUnset for ArrayAccess, plus which syntax triggers each.

for a middle

Explain the details: null offset on append, empty() calling offsetExists then offsetGet, the PHP 8 TypeError from count(), and why array_* functions still reject the object.

for a senior

Diagnose 'Indirect modification of overloaded element' notices and lost writes, and judge when array syntax on a domain object helps readers versus when a toArray() method is clearer.

for a principal

Set a codebase guideline on array-like objects: where they aid a domain model, and where they hide cost or surprise callers who expect real array semantics.

## Two small built-in interfaces PHP ships two interfaces that let an object respond to array-style syntax: - **`Countable`** declares `count(): int`. - **`ArrayAccess`** declares `offsetExists(mixed $offset): bool`, `offsetGet(mixed $offset): mixed`, `offsetSet(mixed $offset, mixed $value): void` and `offsetUnset(mixed $offset): void`. Both are core interfaces with **tentative return types**, so an implementation should declare those return types. ## Countable `count($value)` accepts `Countable|array`. For an object implementing `Countable`, it returns whatever the object's `count()` returns. Before PHP 8.0, counting a non-countable object produced a warning and returned `1`; since **PHP 8.0** it throws `TypeError: count(): Argument #1 ($value) must be of type Countable|array`. A seat map that implements `Countable` can therefore be passed anywhere code calls `count()`. ## ArrayAccess: which syntax calls which method | Code | Method called | |---|---| | `$seats['A12']` | `offsetGet('A12')` | | `$seats['A12'] = $ticket` | `offsetSet('A12', $ticket)` | | `$seats[] = $ticket` | `offsetSet(null, $ticket)` | | `isset($seats['A12'])` | `offsetExists('A12')` | | `empty($seats['A12'])` | `offsetExists('A12')`, then `offsetGet('A12')` only if it returned true | | `unset($seats['A12'])` | `offsetUnset('A12')` | The object decides what an offset means: it can validate seat codes, lazy-load entries, or refuse writes by throwing from `offsetSet()`. ## Where the illusion breaks An `ArrayAccess` object is **not an array**, and several things expose that: 1. **Array functions reject it.** `array_key_exists()`, `in_array()`, `array_map()` and friends are typed `array`. `array_key_exists()` on objects was deprecated in 7.4 and removed in 8.0, so it now throws a `TypeError`. 2. **Iteration is separate.** `foreach` over an object that only implements `ArrayAccess` walks its visible properties, not its offsets. Iterating offsets needs one of the iteration interfaces, which are a different topic. 3. **Nested writes are indirect.** `$seats['A12'][] = 'wheelchair'` does not call `offsetSet()`. PHP calls `offsetGet('A12')`, and if that returns a non-object by value, the change would land on a temporary copy, so PHP raises `Notice: Indirect modification of overloaded element of SeatMap has no effect` and discards it. Increments like `$counts['A12']++` are indirect in the same way. Fixes are to return by reference from `offsetGet()` (`public function &offsetGet(mixed $offset): mixed`), to store objects (which are handles and can be mutated), or to read, modify and write back explicitly. 4. **Value semantics differ.** Passing the object to a function passes a handle, so the callee's writes are visible to the caller, unlike a plain array argument. 5. **`isset()` trusts you.** If `offsetExists()` returns true for a key whose value is `null`, `isset()` reports true, unlike an array. ## Choosing what offsetGet() returns The return mode of `offsetGet()` decides which syntax works on the object: | `offsetGet()` returns | Direct write `$m['k'] = v` | Compound `$m['k'] .= v` | Nested write into the element | |---|---|---|---| | a scalar or array by value | work through `offsetSet()` | work: get, apply, set | notice, change lost | | an object | work | work | works, e.g. `$m['k']->note = v`, through the object handle | | by reference (`&offsetGet`) | work | work | works: the stored element itself is changed | Returning by reference exposes internal storage and bypasses any validation in `offsetSet()`, so many implementations prefer to store small objects, or to accept that nested writes are unsupported and document it. ## When to implement them - A **domain collection** with rules (a seat map that rejects unknown seat codes) reads naturally with `$seats['A12']` and `count($seats)`. - A **configuration or settings object** can offer array syntax while controlling writes. Avoid it when callers will expect `array_*` functions to work; returning a real array from a method such as `toArray()` is simpler than an object that looks like an array until it doesn't.

  • Why does $seats['A12']['note'] = 'wheelchair' raise an 'Indirect modification' notice when offsetGet() returns an array, while $seats['A12'] .= ' (VIP)' works?
    A nested write needs the stored element itself, so PHP fetches it through `offsetGet()` for writing; an array returned by value is a temporary copy, so PHP emits the notice and the change is lost. A compound assignment on one level is different: PHP calls `offsetGet()`, applies the operator and passes the result to `offsetSet()`. Returning by reference from `offsetGet()`, or storing objects, makes nested writes work.
  • How would you make foreach iterate a SeatMap's seats rather than its properties?
    Implement one of PHP's iteration interfaces as well, typically by returning an iterator over the internal array from a `getIterator()` method. `ArrayAccess` covers only offset syntax; iteration is a separate contract, covered with the standard library's iterators.

saying these in an interview costs you the question

  • Implementing ArrayAccess makes array_key_exists() and in_array() work on the object
  • $obj[] = $value passes the next integer index to offsetSet()
  • isset($obj['k']) calls offsetGet() and checks for null
  • count() on any object returns 1 in PHP 8
  • $obj['k'][] = $v always calls offsetSet() with the modified array