Custom Collections in PHP
Why avoid plain arrays?
Arrays in PHP are flexible but lack type safety, which can lead to hidden bugs and unpredictable behavior, especially in large codebases or frameworks like Laravel.
Why Use Custom Collections?
- Type Safety: Custom collections enforce that only specific types of objects are stored, catching errors early ("fail fast").
- Readability: Returning
FacultyCollectioninstead of a generic array makes intent clear—anyone reading the code knows exactly what is inside. - Built-in Methods: Collections can provide methods like
groupBy,first,last,map,filter, etc., out of the box. - Consistency: Using collections creates a unified coding style and predictable behavior across your project.
Avoid using arrays as function inputs/outputs unless required by a framework or external contract.
Example: Typed Collection Implementation
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Read;
use Webmozart\Assert\Assert;
abstract class TypedCollection extends Collection {
public function __construct(array $elements = []) {
Assert::allIsInstanceOf($elements, $this->type());
parent::__construct($elements);
}
abstract protected function type(): string;
public function add(mixed $element): void {
Assert::isInstanceOf($element, $this->type());
parent::add($element);
}
}
<?php
declare(strict_types=1);
namespace App\Context\Foo\Domain\Read\View;
use App\Shared\Domain\Read\TypedCollection;
final class FooCollection extends TypedCollection {
protected function type(): string {
return Foo::class;
}
}
When Should You Use a Custom Collection?
- When logic related to an array is being duplicated in multiple places (e.g., filtering, mapping, counting).
- When you want to decouple clients from the internal structure of your data.
- When you want to provide meaningful, named transformations (e.g.,
$faculties->activeOnly()instead ofarray_filter(...)).
Best Practices
- Immutability: Prefer returning new collection instances for transformations, rather than mutating the original.
- Minimal Interface: Only implement methods that your clients actually use.
- Iterator Support: Implement
IteratorAggregateand return anArrayIteratorfor easy foreach support.
- PHP
final class Names implements IteratorAggregate {
private array $names;
public function __construct(array $names) {
Assert::that()->allIsString($names);
$this->names = $names;
}
public function getIterator(): Iterator {
return new ArrayIterator($this->names);
}
}
Separation of Concerns: Business Logic vs Collections
Placing business logic and domain-specific decisions inside collection classes violates the Single Responsibility Principle and reduces maintainability.
What Collections Should NOT Contain
- ❌ Wrong
- ✅ Right
class PricingPlanCollection extends TypedCollection
{
// ❌ Business decision
public function getCashPlans(): self {
return $this->filter(fn($p) => $p->type === PlanType::Cash);
}
// ❌ Domain rule
public function getDefaultPlan(): ?PricingPlan {
return $this->first(fn($p) => $p->isDefault);
}
}
Problems:
- Tight coupling to business rules
- Hard to test independently
- Violates Open/Closed Principle
class PricingPlanCollection extends TypedCollection
{
protected function type(): string {
return PricingPlan::class;
}
// ✅ Generic filtering
public function filterByCriteria(callable $criteria): self {
return new self($this->filter($criteria)->all());
}
}
class PricingPlanFinderService
{
public function findCashPlans(PricingPlanCollection $plans): PricingPlanCollection {
return $plans->filterByCriteria(
fn($p) => $p->type === PlanType::Cash
);
}
public function findDefaultPlan(PricingPlanCollection $plans): ?PricingPlan {
return $plans->findFirst(fn($p) => $p->isDefault)
?? $plans->findFirst(fn($p) => $p->type === PlanType::Cash);
}
}
Benefits:
- Clear separation of concerns
- Easy to test and maintain
- Business rules can evolve independently
Responsibility Distribution
| Layer | Responsibility | Example |
|---|---|---|
| Collection | Generic data operations | filter(), map(), groupBy() |
| Service | Business logic & decisions | findActivePlans(), calculateTotal() |
| Policy | Access control & permissions | canPurchase(), isAvailable() |
| Model | Simple entity rules | isActive(), isExpired() |
Collections = Data Structure
Services/Policies = Business Logic
Keep collections generic and reusable. Move all domain-specific logic to appropriate service or policy classes.
Summary
Custom collections are a simple but powerful tool for improving code quality, development speed, and reducing bugs in PHP projects. They provide structure, type safety, and clarity—making your codebase more maintainable and robust.
Team CTO Guidance: Avoid General Collections
"General-purpose collections are only about 30% better than arrays. But to achieve the remaining 70% of benefits, we should avoid generic collections as much as possible and instead create highly specific collections. Each collection should have a precise, expected class type. Always ask yourself: Is this collection as specific as it can be?"
Actionable Rule:
- Prefer highly specific collections with strict expected types over generic collections.
- Only use general collections when absolutely necessary.