AdachSoft Collection Library
A simple, focused PHP library for building strongly-typed collections with both mutable and immutable variants. It promotes clear, predictable APIs and removes the pitfalls of loosely-typed arrays.
- PHP 8.3+
- No external runtime dependencies
- PSR-4 autoloading
Key Features
- Mutable and immutable base classes to extend
- Strong runtime type validation for items (and for maps: for keys and values)
- Array-like access []:
- Mutable collections support read/write via []
- Immutable collections support read-only via [] (write operations throw)
- Clean iteration with IteratorAggregate
- Utility type system for maps (scalar and class types)
- NEW in 3.0.1:
- Unified item-type validation in
AbstractCollection and AbstractImmutableCollection via overridable isValidItem()/validateType() with ItemTypeValidationTrait; legacy $type constructor parameter and property are still supported for BC but considered deprecated.
AbstractImmutableMap no longer propagates the legacy $type when creating new instances internally, preparing for future removal of this parameter without breaking userland code.
- NEW in 3.0.0:
- API for first()/last() on collections and maps (throws UnderflowException on empty)
- ReadableCollectionInterface::hasKey(int|string $key) for key type consistency
- Dev tooling: adachsoft/changelog-linter and adachsoft/php-code-style with unified composer scripts
- Previously added highlights:
- Merge API for collections and maps with conflict strategies (OVERWRITE, SKIP, FAIL, APPEND_NEW_KEY)
- toArray() for all collections (preserves keys)
- get() / getOrDefault() for base collections and maps
- Constructors accept iterable in all collections and maps
- Advanced operations: filter, map, reduce, unique, reverse, chunk, take, skip
- PHPStan integration with a custom rule enforcing @extends for collection and map subclasses
- Improved PHPDoc generics for precise static analysis
Installation
composer require adachsoft/collection
PHPStan integration
adachsoft/collection ships with a PHPStan rule (EnforceCollectionExtendsTagRule) enforcing the use of @extends in collection subclasses.
Supported bases and required @extends:
- AbstractCollection => @extends AbstractCollection
- AbstractImmutableCollection => @extends AbstractImmutableCollection
- AbstractMap => @extends AbstractMap<K, V>
- AbstractImmutableMap => @extends AbstractImmutableMap<K, V>
Examples:
final class ImmutableUserCollection extends AbstractImmutableCollection
{
}
final class UserCollection extends AbstractCollection
{
}
final class UserMap extends AbstractMap
{
}
final class UserImmutableMap extends AbstractImmutableMap
{
}
No extra PHPStan configuration is required. The package ships an extension.neon and is auto-included via composer.json extra.phpstan.includes.
Rector migration helpers
To help projects upgrade from the legacy $type-based item validation to the new isValidItem() API introduced in 3.0.1, the library ships two optional Rector rules:
AdachSoft\Collection\Rector\CollectionConstructorTypeToIsValidItemRector
AdachSoft\Collection\Rector\ImmutableMapRemoveConstructorTypeArgumentRector
These rules are purely development-time helpers; they do not change how the library behaves at runtime.
What they do
CollectionConstructorTypeToIsValidItemRector
Targets subclasses of AbstractCollection and AbstractImmutableCollection that still rely on:
parent::__construct($items, Foo::class), or
protected ?string $type = Foo::class;
The rule:
- generates
protected function isValidItem(mixed $item): bool based on Foo::class (with a helpful @param Foo $item phpdoc),
- removes the second
$type argument from parent::__construct(...) calls,
- keeps constructor signatures compatible with the base classes (to avoid "Too many arguments" at runtime),
- removes the legacy
$type property when it is safe to do so and it is not used elsewhere in the class.
ImmutableMapRemoveConstructorTypeArgumentRector
Targets subclasses of AbstractImmutableMap that still call:
parent::__construct($items, SomeClass::class);
The rule removes the unused second argument so that your map constructors match the internal behavior of AbstractImmutableMap in 3.0.1. Key/value validation stays defined solely by getKeyType() and getValueType().
How to use
The package ships a ready-made Rector config at:
vendor/adachsoft/collection/rector/collection-301-migration.php
You can either:
- Run Rector directly with this config:
vendor/bin/rector process src \
--config=vendor/adachsoft/collection/rector/collection-301-migration.php
- Or import it from your main
rector.php:
<?php
use Rector\Config\RectorConfig;
return static function (RectorConfig $rectorConfig): void {
$rectorConfig->import(__DIR__ . '/vendor/adachsoft/collection/rector/collection-301-migration.php');
};
Then run Rector as usual in your project root:
vendor/bin/rector process
The rules will only touch your collection/map subclasses that still use the legacy $type API.
Quick Start
1) Your domain object
<?php
final class User
{
public function __construct(
public readonly int $id,
public readonly string $name,
) {}
}
2) Mutable collection
Extend AdachSoft\Collection\AbstractCollection and define the expected item type.
Since 3.0.1 the recommended way is to override isValidItem() for custom validation logic. The legacy $type property and constructor parameter are still supported for backward compatibility but are considered deprecated and may be removed in a future major version.
<?php
use AdachSoft\Collection\AbstractCollection;
final class UserCollection extends AbstractCollection
{
protected function isValidItem(mixed $item): bool
{
return $item instanceof User;
}
}
Usage:
$user1 = new User(1, 'Alice');
$user2 = new User(2, 'Bob');
$users = new UserCollection([$user1]);
$users->add($user2);
$users[] = new User(3, 'C');
foreach ($users as $user) {
echo $user->name . "\n";
}
$users->add('not a user');
Legacy style (still supported for BC): you can also set a protected $type property with a FQCN of the expected item type. This pattern is deprecated and will be removed after the $type constructor parameter is dropped.
final class LegacyUserCollection extends AbstractCollection
{
protected ?string $type = User::class;
}
3) Immutable collection (read-only, array-like access for reads)
Extend AdachSoft\Collection\AbstractImmutableCollection. No mutator methods exist; read access via [] is allowed, write attempts via [] throw.
As with mutable collections, since 3.0.1 the recommended approach for item type validation is to override isValidItem(); the legacy $type property/constructor parameter remain supported but are considered deprecated.
<?php
use AdachSoft\Collection\AbstractImmutableCollection;
final class ImmutableUserCollection extends AbstractImmutableCollection
{
protected function isValidItem(mixed $item): bool
{
return $item instanceof User;
}
}
Usage:
$users = new ImmutableUserCollection([
new User(1, 'Alice'),
new User(2, 'Bob'),
]);
echo $users[0]->name;
var_dump(isset($users[1]));
$users[0] = new User(3, 'Charlie');
unset($users[0]);
$names = $users->map(fn (User $u) => $u->name);
4) Advanced Collection Operations
All collections (both mutable and immutable) support functional operations:
<?php
use AdachSoft\Collection\AbstractImmutableCollection;
final class IntCollection extends AbstractImmutableCollection {}
$numbers = new IntCollection([1, 2, 3, 4, 5, 5, 6]);
$sum = $numbers->reduce(fn ($carry, $item) => $carry + $item, 0);
$unique = $numbers->unique();
$users = new UserCollection([
new User(1, 'Alice'),
new User(2, 'Bob'),
new User(1, 'Alice2'),
]);
$uniqueUsers = $users->unique(fn ($a, $b) => $a->id === $b->id);
$reversed = $numbers->reverse();
$chunks = $numbers->chunk(3);
$firstThree = $numbers->take(3);
$withoutFirst = $numbers->skip(2);
All operations return new collection instances, preserving the original collection type and maintaining key associations where applicable.
4.1) get(), getOrDefault(), first(), last()
$users = new UserCollection([
'owner' => new User(1, 'Alice'),
]);
$owner = $users->get('owner');
$guest = $users->getOrDefault('guest');
$guest2 = $users->getOrDefault('guest', new User(0, 'Guest'));
$values = new class (['a' => null]) extends AdachSoft\Collection\AbstractCollection {};
var_dump($values->getOrDefault('a', 'x'));
var_dump($values->getOrDefault('b', 'x'));
$first = $users->first();
$last = $users->last();
5) Immutable, typed map (keys and values)
Use AdachSoft\Collection\AbstractImmutableMap for key/value collections with strict, explicit types for keys and values. Maps now also expose first()/last() returning values.
<?php
use AdachSoft\Collection\AbstractImmutableMap;
use AdachSoft\Collection\Type\TypeDefinitionInterface;
use AdachSoft\Collection\Type\ScalarTypeEnum;
use AdachSoft\Collection\Type\ClassType;
final class UserMap extends AbstractImmutableMap
{
protected function getKeyType(): TypeDefinitionInterface
{
return ScalarTypeEnum::STRING;
}
protected function getValueType(): TypeDefinitionInterface
{
return new ClassType(User::class);
}
}
Usage:
$map = new UserMap([
'owner' => new User(1, 'Alice'),
'guest' => new User(2, 'Bob'),
]);
$owner = $map->get('owner');
$ownerOrDefault = $map->getOrDefault('admin', null);
$first = $map->first();
$last = $map->last();
foreach ($map as $key => $user) {
echo $key . ': ' . $user->name . "\n";
}
$all = $map->toArray();
$allKeys = iterator_to_array($map->keys());
$allVals = iterator_to_array($map->values());
Note: starting from 3.0.1, AbstractImmutableMap does not internally propagate the legacy $type parameter when creating new instances (e.g. in map/filter). Key/value validation is fully driven by getKeyType()/getValueType().
6) Mutable, typed map
Use AdachSoft\Collection\AbstractMap to allow in-place mutation of key/value pairs while keeping strict types. Maps now also expose first()/last().
<?php
use AdachSoft\Collection\AbstractMap;
use AdachSoft\Collection\Type\TypeDefinitionInterface;
use AdachSoft\Collection\Type\ScalarTypeEnum;
use AdachSoft\Collection\Type\ClassType;
final class MutableUserMap extends AbstractMap
{
protected function getKeyType(): TypeDefinitionInterface
{
return ScalarTypeEnum::STRING;
}
protected function getValueType(): TypeDefinitionInterface
{
return new ClassType(User::class);
}
}
Usage:
$map = new MutableUserMap([
'x' => new User(1, 'Alice'),
]);
$map->set('x', new User(2, 'Bob'));
$map->removeKey('x');
$map->clear();
$values = $map->values();
$keys = $map->keys();
$all = $map->toArray();
$first = $map->first();
$last = $map->last();
7) Constructors accept iterable
All collections and maps accept iterable as constructor argument. This means you can pass arrays, generators, or other collections.
Note: Keys must be of type int|string. In maps, invalid key/value types will throw domain exceptions at construction time.
8) Merge API for collections and maps
The library provides a unified merge method for both collections and maps via MergeableCollectionInterface.
Signature:
public function merge(
iterable $source,
MergeConflictModeEnum $mode = MergeConflictModeEnum::OVERWRITE,
bool $preserveKeys = true,
?string $appendSuffix = '_copy'
): static;
Rules summary:
- Immutable classes return a new instance with changes; mutable classes modify
$this.
- Collections:
$preserveKeys === false appends; $preserveKeys === true respects keys with conflict strategies.
- Maps:
$preserveKeys is ignored; APPEND_NEW_KEY: string suffix for string-key maps, next free int for int-key maps.
Exceptions:
- InvalidArgumentException, MergeConflictException, InvalidKeyTypeException, InvalidItemTypeException as appropriate.
Contracts
Prefer the contracts from AdachSoft\Collection\Contract:
ReadableCollectionInterface for read-only collections (now with first/last/hasKey(int|string))
MutableCollectionInterface for mutable collections
KeyedCollectionInterface for maps (now with first/last)
MutableKeyedCollectionInterface for mutable maps
MergeableCollectionInterface for a unified merge operation across collections and maps
Tooling
- PHPUnit tests:
composer test
- Coding style:
composer csfix
- Static analysis:
composer phpstan
Changelog
See CHANGELOG.md for release notes, including BC breaks in 3.0.0 and the incremental changes in 3.0.1.
License
MIT