explain-me-data-container
DataContainer: a typed array object
Reference for DataContainer in
crud-assistant ↗.
Written for a competent engineer who is new to PHP: just enough language semantics to read the code, then the container's own rules.
Non-interactive by design — come back when in doubt. CLI truth: php src/demo.php.
View full src/demo.php (faithful replica of IsDataContainer)
<?php
declare(strict_types=1);
// True source: https://github.com/juaniquillo/crud-assistant/blob/main/src/Concerns/IsDataContainer.php
// Minimal faithful replica for the lesson (no composer dep). Behavior matches the
// trait for every method demonstrated below.
/**
* @template TValue
* @implements IteratorAggregate<array-key, TValue>
*/
final class DemoContainer implements IteratorAggregate, Countable, ArrayAccess
{
/** @var array<array-key, TValue> */
protected array $data = [];
/** @param array<array-key, TValue> $data */
public function __construct(array $data = [])
{
$this->fill($data);
}
/** @param array<array-key, TValue> $data */
public static function make(array $data = []): static
{
return new static($data);
}
/** @return TValue */
public function __get(string $name): mixed
{
if (!array_key_exists($name, $this->data)) {
throw new InvalidArgumentException("The {$name} key does not exist in the container.");
}
return $this->data[$name];
}
/** @param TValue $value */
public function __set(string $name, mixed $value): void
{
$this->data[$name] = $value;
}
public function __isset(string $name): bool
{
return isset($this->data[$name]);
}
public function __unset(string $name): void
{
unset($this->data[$name]);
}
public function __toString(): string
{
return json_encode($this->data) ?: '{}';
}
/** @return TValue */
public function get(mixed $name): mixed
{
if (!array_key_exists($name, $this->data)) {
throw new InvalidArgumentException("The {$name} key does not exist in the container.");
}
return $this->data[$name];
}
/** @param TValue $value */
public function set(mixed $name, mixed $value): static
{
$this->data[$name] = $value;
return $this;
}
/** @param array<array-key, TValue> $data */
public function fill(array $data): static
{
$this->data = $data;
return $this;
}
/** @param array<array-key, TValue> $data */
public function merge(array $data): static
{
$this->data = array_merge($this->data, $data);
return $this;
}
/** @param TValue $value */
public function push(mixed $value): static
{
$this->data[] = $value;
return $this;
}
/** @param array<int, array-key> $keys */
public function contains(array $keys): bool
{
foreach ($keys as $key) {
if (!array_key_exists($key, $this->data)) {
return false;
}
}
return true;
}
/**
* @param array<int, array-key> $keys
*
* @return array-key|false
*/
public function missing(array $keys): mixed
{
foreach ($keys as $key) {
if (!array_key_exists($key, $this->data)) {
return $key;
}
}
return false;
}
/** @return array<array-key, TValue> */
public function toArray(): array
{
return $this->data;
}
/** @return array<array-key, TValue> */
public function all(): array
{
return $this->toArray();
}
/** @return ArrayIterator<array-key, TValue> */
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->data);
}
public function count(): int
{
return count($this->data);
}
public function offsetSet(mixed $offset, mixed $value): void
{
if ($offset === null) {
$this->data[] = $value;
} else {
$this->data[$offset] = $value;
}
}
public function offsetExists(mixed $offset): bool
{
return isset($this->data[$offset]);
}
public function offsetUnset(mixed $offset): void
{
unset($this->data[$offset]);
}
/** @return TValue */
public function offsetGet(mixed $offset): mixed
{
if (!array_key_exists($offset, $this->data)) {
throw new InvalidArgumentException("The {$offset} key does not exist in the container.");
}
return $this->data[$offset];
}
}
function show(string $label, mixed $value): void
{
echo str_pad($label, 28) . json_encode($value) . PHP_EOL;
}
echo "== 1. Three read styles agree (when key exists) ==" . PHP_EOL;
$c = DemoContainer::make(['name' => 'Ada', 'role' => 'admin']);
show('get(name)', $c->get('name'));
show('$c[name]', $c['name']);
show('$c->name', $c->name);
echo PHP_EOL . "== 2. Missing-key reads throw (all three) ==" . PHP_EOL;
foreach (['get()', 'offsetGet', '__get'] as $style) {
try {
match ($style) {
'get()' => $c->get('nope'),
'offsetGet' => $c['nope'],
'__get' => $c->nope,
};
echo "$style: NO THROW (unexpected)" . PHP_EOL;
} catch (InvalidArgumentException $e) {
echo "$style throws: " . $e->getMessage() . PHP_EOL;
}
}
echo PHP_EOL . "== 3. fill (replace) vs merge (array_merge) vs push (append) ==" . PHP_EOL;
$c->fill(['a' => 1]);
show('after fill', $c->toArray());
$c->merge(['b' => 2]);
show('after merge', $c->toArray());
$c->push(99);
show('after push', $c->toArray());
$ints = DemoContainer::make([10, 20]);
$ints->merge([30]); // array_merge renumbers int keys
show('int keys merged', $ints->toArray());
echo PHP_EOL . "== 4. null-value trap: isset vs array_key_exists ==" . PHP_EOL;
$n = DemoContainer::make(['maybe' => null]);
show('contains([maybe])', $n->contains(['maybe'])); // true (array_key_exists)
show('isset($n->maybe)', isset($n->maybe)); // false (isset)
show('isset($n[maybe])', isset($n['maybe'])); // false (offsetExists uses isset)
show('missing([maybe])', $n->missing(['maybe'])); // false = not missing
echo "get('maybe') returns: " . var_export($n->get('maybe'), true) . " (no throw)" . PHP_EOL;
echo PHP_EOL . "== 5. contains/missing + count/iterate/toString ==" . PHP_EOL;
$d = DemoContainer::make(['x' => 1, 'y' => 2]);
show('contains([x,y])', $d->contains(['x', 'y']));
show('contains([x,z])', $d->contains(['x', 'z']));
show('missing([x,z])', $d->missing(['x', 'z']));
show('count()', count($d));
echo "foreach: ";
foreach ($d as $k => $v) {
echo "$k=$v ";
}
echo PHP_EOL;
echo "toString: " . ((string) $d) . PHP_EOL;
show('toArray==all', $d->toArray() === $d->all());
echo PHP_EOL . "== 6. @template TValue is phpdoc-only ==" . PHP_EOL;
$mixed = DemoContainer::make(['s' => 'str', 'i' => 42, 'a' => [1]]);
show('mixed types accepted', $mixed->toArray());
echo PHP_EOL . "lesson from explain-me-template-data-container (template: https://github.com/juaniquillo/explain-me-template)" . PHP_EOL;
True sources: src/Concerns/IsDataContainer.php (logic) + src/Contracts/DataContainerInterface.php (contract). This file is the in-repo replica the snippets below are taken from.
What it is
DataContainer is a final single-generic
(@template TValue) wrapper around array<array-key, TValue>.
The class itself is 29 lines — use IsDataContainer + static::make() —
with all behavior in the IsDataContainer trait and the contract in
DataContainerInterface
(IteratorAggregate + Countable + ArrayAccess,
get/set/fill/merge/push/contains/missing/toArray/all).
Why it exists: bare PHP arrays allow any shape, so CRUD payloads passed between actions get no object syntax,
no missing-key errors, and no element typing. This gives $c->key /
$c['key'] / $c->get('key'), throwing reads,
contains()/missing() guards, count()/foreach/JSON dump,
and @var array<array-key, TValue> for PHPStan/Psalm — while staying a plain in-memory array.
PHP you need to read it
| Construct | What it means here |
|---|---|
| final class | Cannot be extended. So static::make() behaves like new self. |
| trait IsDataContainer | Reusable method bundle pasted into the class with use. Other containers reuse it. |
| interface … : IteratorAggregate, Countable, ArrayAccess | Promises foreach, count(), and $c[$k] support. The trait provides the bodies. |
| __get / __set / __isset / __unset / __toString | Magic methods: called for $c->key, isset($c->k), unset($c->k), (string)$c. |
| declare(strict_types=1) | Scalar type coercion is strict in this file. Does not affect the container's value typing. |
| @template TValue | PHPDoc generic for static analysis only (PHPStan/Psalm). Zero runtime enforcement. |
1. Three read styles agree (on a hit)
$c = DemoContainer::make(['name' => 'Ada', 'role' => 'admin']); $c->get('name'); // "Ada" $c['name']; // "Ada" $c->name; // "Ada" (via __get)
Writes differ in chainability, not value: set() returns static, __set/offsetSet return void.
2. All three throw on a miss
$c->get('nope'); // InvalidArgumentException: The nope key does not exist… $c['nope']; // same throw (offsetGet) $c->nope; // same throw (__get)
Guard first with contains(['a','b']) (bool) or missing(['a','b']) (first missing key, or false).
3. fill vs merge vs push
$c->fill(['a' => 1]); // REPLACE whole array $c->merge(['b' => 2]); // array_merge: string keys overwritten… $c->push(99); // $data[] = 99 (append) $c[] = 99; // same append (offsetSet with null offset)
after fill {"a":1}
after merge {"a":1,"b":2}
after push {"a":1,"b":2,"0":99}
Int-key warning: merge is array_merge, so integer keys are renumbered and appended, never overwritten by key: [10,20] + merge([30]) → [10,20,30].
4. The null trap: isset lies
$n = DemoContainer::make(['maybe' => null]); $n->contains(['maybe']); // true (array_key_exists) $n->missing(['maybe']); // false (not missing) $n->get('maybe'); // NULL, no throw isset($n->maybe); // false (__isset uses isset) isset($n['maybe']); // false (offsetExists uses isset)
Rule: never use isset to test presence. Reads and contains()/missing() use array_key_exists (null counts as present); isset paths use isset() (null counts as absent).
5. Count, iterate, dump
count($d); // 2 (Countable) foreach ($d as $k => $v) { /* … */ } // getIterator() (string) $d; // '{"x":1,"y":2}' (__toString = json_encode ?: '{}') $d->toArray(); // ['x' => 1, 'y' => 2] $d->all(); // identical alias
6. @template TValue is analysis-only
/** @template TValue @var array<array-key, TValue> $data */ DemoContainer::make(['s' => 'str', 'i' => 42, 'a' => [1]]); // runs fine
Runtime enforces only array. Mixed value types pass; element discipline comes from PHPStan/Psalm, not PHP.
Gotchas checklist
- Missing reads throw —
get(),[],->all raiseInvalidArgumentException. fillreplaces;mergeisarray_merge(int keys renumber);push/$c[]appends.contains/missing/getseenullas present;isset($c->k)/isset($c[k])do not.toArray()andall()are the same;__unset/offsetUnsetsilently ignore missing keys.
Sources
src/demo.php(this repo — the thing being explained)- Lesson target: DataContainer.php ↗
- Logic:
src/Concerns/IsDataContainer.php, contract:src/Contracts/DataContainerInterface.php