PHP code example of zenstruck / collection

1. Go to this page and download the library: Download zenstruck/collection library. Choose the download type require.

2. Extract the ZIP file and open the index.php.

3. Add this code to the index.php.
    
        
<?php
require_once('vendor/autoload.php');

/* Start to develop here. Best regards https://php-download.com/ */

    

zenstruck / collection example snippets


use function Zenstruck\collect;

$page = collect($users) // array, iterable, closure or doctrine collection
    ->filter(fn(User $user) => $user->isActive())
    ->map(fn(User $user) => $user->email())
    ->paginate(page: 2, limit: 10)
;

\count($page);       // 10 (the number of items on this page)
$page->totalCount(); // 79 (the total number of items)
$page->lastPage();   // 8

foreach ($page as $email) {
    // ...
}

use Zenstruck\Collection\Doctrine\ORM\EntityResult;

$qb = $em->createQueryBuilder()
    ->select('p')
    ->from(Post::class, 'p')
    ->where('p.status = :status')
    ->setParameter('status', 'published')
;

$published = new EntityResult($qb); // nothing has been executed yet

\count($published);                       // SELECT COUNT(...)
$published->first();                      // ...LIMIT 1
$published->paginate(page: 2, limit: 10); // a single, paginated query
$published->asArray('id', 'title')->first(); // ['id' => 1, 'title' => '...'] - no entity hydrated

foreach ($published as $post) {
    // ...
}

use function Zenstruck\collect;

collect(['a', 'b']);                 // ArrayCollection
collect(new \ArrayIterator(['a']));  // LazyCollection
collect(fn() => fetch_rows());       // LazyCollection (callback not executed yet)
collect($doctrineCollection);        // DoctrineBridgeCollection
collect();                           // empty LazyCollection

/** @var Zenstruck\Collection<Post> $posts */

// transformations - each returns a new Collection, the original is untouched
$posts->filter(fn(Post $post) => $post->isPublished());
$posts->map(fn(Post $post) => $post->title());
$posts->keyBy(fn(Post $post) => $post->id());
$posts->take(10);    // first 10
$posts->take(10, 5); // 10, starting at offset 5

// reductions
$posts->first();                                          // first item or null
$posts->first($default);                                  // first item or $default
$posts->find(fn(Post $post) => $post->isPublished());     // first match or null
$posts->reduce(fn(int $count, Post $post) => $count + 1, 0);
$posts->isEmpty();
\count($posts);

foreach ($posts as $post) {
    // ...
}

$posts->eager();     // load everything into an ArrayCollection
$posts->paginate();  // see "Pagination" below
$posts->dump();      // dump the items and return $this
$posts->dd();        // dump the items and die

$posts->filter(fn(Post $post, int $key) => $post->isPublished());

$titles = collect(fn() => fetch_rows()) // nothing has run yet
    ->filter(fn(array $row) => $row['published'])
    ->map(fn(array $row) => $row['title'])
    ->take(10)
; // still nothing has run

foreach ($titles as $title) {
    // NOW the source is iterated - and stops after 10 matches
}

> new LazyCollection($generator);        // throws \InvalidArgumentException
> new LazyCollection(fn() => $items());  // ok - re-invoked on each iteration
> 

use Zenstruck\Collection\ArrayCollection;

$collection = new ArrayCollection(['a' => 1, 'b' => 2]);

$collection->set('c', 3); // new instance, $collection is unchanged

use Zenstruck\Collection\LazyCollection;

$users = new LazyCollection(function() {
    $page = 1;

    while ($response = $api->get('/users', ['page' => $page++])) {
        yield from $response->toArray();
    }
});

$users->take(50); // only fetches as many pages as needed

use Zenstruck\Collection\CallbackCollection;
use Zenstruck\Collection\ChainCollection;
use Zenstruck\Collection\FactoryCollection;

use function Zenstruck\collect;

new ChainCollection([$collection1, $collection2]);       // keys are discarded
new ChainCollection([$collection1, $collection2], true); // keys are preserved

// count without iterating
new CallbackCollection(fn() => $api->results(), fn() => $api->totalCount());

// $post is only created for items you actually iterate over
new FactoryCollection(collect($rows), fn(array $row) => Post::fromArray($row));

/** @var Zenstruck\Collection<Post> $posts */

$page = $posts->paginate();                  // page 1, 20 per page
$page = $posts->paginate(page: 3, limit: 50);

foreach ($page as $post) {
    // only the 50 posts on page 3
}

$page = $posts->paginate(page: 2, limit: 20); // takes 21 items, starting at 20

$page->currentPage();    // 2
\count($page);           // 20
$page->hasMorePages();   // true
$page->nextPage();       // 3
$page->previousPage();   // 1
$page->haveToPaginate(); // true

foreach ($page as $post) {
    // ...
}

$page->totalCount(); // 138
$page->lastPage();   // 7
$page->pageCount();  // 7
$page->firstPage();  // 1

$page = $posts->paginate(page: 999, limit: 20)->strict();

$page->currentPage(); // 7 - the last page, not 999
\count($page);        // 18 - and these are the last page's items

foreach ($posts->pages(100) as $page) {
    foreach ($page as $post) {
        // ...
    }
}

$pages = $posts->pages(100);

$pages->get(3);  // the Page for page 3
\count($pages);  // the number of pages (0 when the collection is empty)

use Pagerfanta\Pagerfanta;
use Zenstruck\Collection\Pagerfanta\PagerfantaAdapter;

$pagerfanta = new Pagerfanta(new PagerfantaAdapter($posts));

use Zenstruck\Collection\Doctrine\ORM\EntityResult;
use Zenstruck\Collection\Doctrine\ORM\EntityResultQueryBuilder;

// wrap any query builder
$result = new EntityResult($qb);

// ...or use the one that can create the result itself
$result = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
    ->where('p.status = :status')
    ->setParameter('status', 'published')
    ->result()
;

\count($result);          // SELECT COUNT(...)
$result->first();         // ...LIMIT 1 (or null when there are no rows)
$result->first($default); // ...or your default
$result->paginate();      // one paginated query - see "Pagination"
$result->take(10, 20);    // 10 rows, starting at offset 20
$result->eager();         // everything, as an ArrayCollection
$result->isEmpty();

foreach ($result as $post) {
    // ...
}

$page = $result->paginate(page: 2, limit: 20);

$page->hasMorePages(); // SELECT ... LIMIT 21 OFFSET 20
$page->nextPage();     // (already fetched)

$page->totalCount();   // SELECT COUNT(...)
$page->lastPage();     // (already counted)

$recent = $result->filter(Spec::gt('publishedAt', $cutoff)); // $result is unchanged

$result->asArray();              // ['id' => 1, 'title' => 'My Post', ...]
$result->asArray('id', 'title'); // ['id' => 1, 'title' => 'My Post']
$result->asInt('id');            // 1, 2, 3...

// entities in, DTOs out
$dtos = $result->as(fn(Post $post) => PostDto::from($post));

// only select what the DTO needs, and never hydrate an entity at all
$dtos = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
    ->select('p.id, p.title')
    ->result()
    ->as(fn(array $row) => new PostDto($row['id'], $row['title']))
;

// or let asArray() pick the fields
$dtos = $result
    ->asArray('id', 'title')
    ->as(fn(array $row) => new PostDto(...$row))
;

$total = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
    ->select('SUM(p.views)')
    ->result()
    ->asInt()
    ->first()
;

$deleted = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
    ->delete()
    ->where('p.status = :status')
    ->setParameter('status', 'spam')
    ->result()
    ->asInt()
    ->first()
;

$updated = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
    ->update()
    ->set('p.status', ':status')
    ->setParameter('status', 'archived')
    ->where('p.publishedAt < :cutoff')
    ->setParameter('cutoff', $cutoff)
    ->result()
    ->asInt()
    ->first()
;

foreach ($result->readonly() as $post) {
    // $post is not managed
}

$result = EntityResultQueryBuilder::forEntity($em, Post::class, 'p')
    ->leftJoin('p.comments', 'c')
    ->addSelect('COUNT(c.id) AS commentCount')
    ->groupBy('p.id')
    ->result()
    ->withAggregates()
;

foreach ($result as $post) {
    $post->title();      // proxied to the Post
    $post->commentCount; // the aggregate column
    $post->entity();     // the Post itself
    $post->aggregates(); // ['commentCount' => 12]
}

$result = $result->disableFetchJoins();     // faster when the query has no fetch-joined collections
$result = $result->disableOutputWalkers();
$result = $result->enableOutputWalkers();   // 

use Zenstruck\Collection\Spec;

/** @var Zenstruck\Collection\Doctrine\ObjectRepository<Post> $posts */

$posts->find(1);                     // a single Post, or null
$posts->find(['slug' => 'my-post']);

$published = $posts->filter(Spec::eq('status', 'published')); // no query yet

$published->paginate(page: 2);
$published->asArray('id', 'title');

\count($posts);

foreach ($posts as $post) {
    // ...
}

use Zenstruck\Collection\Doctrine\ORM\EntityRepository;
use Zenstruck\Collection\Spec;

$posts = new EntityRepository($em, Post::class);

$posts->find(1);
$posts->filter(Spec::eq('status', 'published'));

use Doctrine\ORM\EntityManagerInterface;
use Zenstruck\Collection\Doctrine\ORM\EntityRepository;
use Zenstruck\Collection\Doctrine\ORM\EntityResult;

/**
 * @extends EntityRepository<Post>
 */
final class PostRepository extends EntityRepository
{
    public function __construct(EntityManagerInterface $em)
    {
        parent::__construct($em, Post::class);
    }

    /**
     * @return EntityResult<Post>
     */
    public function published(): EntityResult
    {
        return $this->qb('p')
            ->where('p.status = :status')
            ->setParameter('status', 'published')
            ->result()
        ;
    }
}

use Zenstruck\Collection\Doctrine\ORM\Bridge\ORMEntityRepository;

/**
 * @extends ORMEntityRepository<Post>
 */
final class PostRepository extends ORMEntityRepository
{
}

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
    // ...
}

use Doctrine\Persistence\ManagerRegistry;
use Zenstruck\Collection\Doctrine\ORM\Bridge\ORMServiceEntityRepository;

/**
 * @extends ORMServiceEntityRepository<Post>
 */
final class PostRepository extends ORMServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Post::class);
    }
}

use Doctrine\ORM\QueryBuilder;
use Zenstruck\Collection\Spec;

$posts->find(1);                                   // by id
$posts->find(['slug' => 'my-post']);               // by criteria
$posts->find(Spec::eq('slug', 'my-post'));         // by specification
$posts->find(fn(QueryBuilder $qb, string $alias) => $qb->andWhere("{$alias}.views > 100"));

$published = $posts->filter(Spec::eq('status', 'published'));
$published = $posts->filter(['status' => 'published']);
$everything = $posts->filter(null);

$published->paginate();
$published->asArray('id', 'title');

$published->filter(Spec::contains('title', 'symfony')); // ok
$published->filter(DoctrineSpec::delete());             // throws InvalidSpecification

use Zenstruck\Collection\Doctrine\DoctrineSpec;

$deleted = $posts
    ->query(DoctrineSpec::andX(
        DoctrineSpec::lt('publishedAt', $cutoff),
        DoctrineSpec::delete(),
    ))
    ->first() // executes the DELETE and returns the number of affected rows
;

use Doctrine\ORM\QueryBuilder;

final class Trending
{
    public function __invoke(QueryBuilder $qb, string $alias): void
    {
        $qb
            ->andWhere("{$alias}.views > 1000")
            ->addOrderBy("{$alias}.views", 'DESC')
        ;
    }
}

$posts->filter(new Trending());
$posts->find(new Trending());

\count($posts);

foreach ($posts as $post) {
    // ...
}

use Zenstruck\Collection\Doctrine\Batch;

/** @var iterable<Post> $posts */

// read: nothing is flushed, the entity manager is cleared after each chunk
foreach (Batch::iterate($posts, $em) as $post) {
    $csvExporter->addRow([$post->id(), $post->title()]);
}

// write: flushed and cleared after each chunk
foreach (Batch::process($posts, $em) as $post) {
    $post->recalculateScore(); // no persist() or flush() needed
}

Batch::process($posts, $em, chunkSize: 500);

foreach (Batch::process($csvRows, $em) as $row) {
    $em->persist(Post::fromRow($row)); // flushed every 100 rows, in one transaction
}

foreach (Batch::iteratorFor($qb) as $post) {
    // ...
}

foreach (Batch::processorFor($query, chunkSize: 50) as $post) {
    // ...
}

> use Symfony\Component\Console\Style\SymfonyStyle;
>
> /** @var SymfonyStyle $io */
>
> // if $csvRows is countable, this is a progress bar with a limit - otherwise it's open ended
> foreach ($io->progressIterate(Batch::process($csvRows, $em)) as $row) {
>     $em->persist(Post::fromRow($row));
> }
> 

use function Zenstruck\collect;

$comments = collect($post->getComments()); // a DoctrineBridgeCollection

// the Doctrine API
$comments->add($comment);
$comments->removeElement($comment);
$comments->containsKey(3);

// ...and this package's
$comments->paginate(page: 2, limit: 10);
$comments->filter(Spec::eq('approved', true));
$comments->map(fn(Comment $comment) => $comment->author());

// a single query with a WHERE, not "load all comments, then filter"
$approved = $comments->filter(Spec::eq('approved', true));

// paginated queries, not one big fetch
foreach ($comments as $comment) {
    // ...
}

use Zenstruck\Collection\Spec;

$posts->filter(Spec::andX(
    Spec::eq('status', 'published'),
    Spec::contains('title', 'symfony'),
    Spec::sortDesc('publishedAt'),
));

> use Zenstruck\Collection\Specification\Filter\Between;
>
> Spec::between('publishedAt', $start, $end);                           // both een::INCLUSIVE_BEGIN); // begin 

Spec::contains('title', 'my*post');   // LIKE '%my%post%'
Spec::startsWith('title', 'my*post'); // LIKE 'my%post%'
Spec::endsWith('title', 'my*post');   // LIKE '%my%post'

Spec::contains('title', '*symfony*'); // identical to Spec::contains('title', 'symfony')

Spec::contains('title', '50%');     // titles containing "50%"
Spec::startsWith('code', 'a_b');    // codes starting with "a_b" - the underscore isn't a wildcard

use Zenstruck\Collection\Doctrine\DoctrineSpec;

$posts->filter(DoctrineSpec::andX(
    DoctrineSpec::eq('status', 'published'),
    DoctrineSpec::innerJoin('category')
        ->eager()                                  // also SELECT the category
        ->scope(DoctrineSpec::eq('name', 'php')),  // category.name = 'php'
    DoctrineSpec::antiJoin('comments'),            // ...that nobody has commented on
));

use Doctrine\ORM\QueryBuilder;

$posts->filter(Spec::callback(
    fn(QueryBuilder $qb, string $alias) => $qb->andWhere("{$alias}.views > 100"),
));

use Doctrine\Common\Collections\Criteria;

$comments->filter(Spec::callback(
    fn(Criteria $criteria) => $criteria->andWhere(Criteria::expr()->gt('score', 10)),
));

$comments->filter(Spec::callback(
    fn(Criteria $criteria) => Criteria::expr()->gt('score', 10),
));

use Zenstruck\Collection\Spec;
use Zenstruck\Collection\Specification\Nested;

final class Published implements Nested
{
    public function specification(): mixed
    {
        return Spec::andX(
            Spec::eq('status', 'published'),
            Spec::isNull('deletedAt'),
        );
    }
}

$posts->filter(new Published());
$posts->filter(Spec::not(new Published()));

// config/bundles.php

return [
    // ...
    Zenstruck\Collection\Symfony\ZenstruckCollectionBundle::class => ['all' => true],
];

use Zenstruck\Collection\Doctrine\ObjectRepositoryFactory;

final class PostController
{
    public function __construct(private ObjectRepositoryFactory $repositories)
    {
    }

    public function index(): Response
    {
        $posts = $this->repositories->create(Post::class); // an ObjectRepository<Post>

        // ...
    }
}

use Zenstruck\Collection\Doctrine\ObjectRepository;
use Zenstruck\Collection\Symfony\Attributes\ForObject;

final class PostController
{
    public function __construct(
        #[ForObject(Post::class)]
        private ObjectRepository $posts,
    ) {
    }
}

use Zenstruck\Collection\Doctrine\ORM\EntityRepository;
use Zenstruck\Collection\Doctrine\ORM\EntityResult;
use Zenstruck\Collection\Symfony\Attributes\ForObject;

/**
 * @extends EntityRepository<Post>
 */
#[ForObject(Post::class)]
final class PostRepository extends EntityRepository
{
    /**
     * @return EntityResult<Post>
     */
    public function published(): EntityResult
    {
        return $this->qb('p')
            ->where('p.status = :status')
            ->setParameter('status', 'published')
            ->result()
        ;
    }
}

/** @var Collection<Post> $posts */

$posts->first();                                  // Post|null
$posts->map(fn(Post $post) => $post->title());    // Collection<string>
$posts->paginate();                               // Page<Post,int>
$posts->eager()->all();                           // array<Post>

collect(['a', 'b']);      // ArrayCollection<string>
collect($doctrineThing);  // DoctrineBridgeCollection<Post>

/** @var EntityRepository<Post> $posts */

$posts->find(1);                                    // Post|null
$posts->query(null);                                // EntityResult<Post>
$posts->query(null)->asInt('id');                   // EntityResult<int>
$posts->query(null)->as(fn(Post $p) => $p->dto());  // EntityResult<PostDto>
$posts->query(null)->withAggregates();              // EntityResult<EntityWithAggregates<Post>>

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Zenstruck\Collection\LazyCollection;

/** @var HttpClientInterface $client */

$query = ['q' => 'repo:symfony/symfony is:issue'];

$issues = new LazyCollection(function() use ($client, $query) {
    $page = 1;

    while ($items = $client->request('GET', 'https://api.github.com/search/issues', [
        'query' => $query + ['per_page' => 100, 'page' => $page++],
    ])->toArray()['items']) {
        yield from $items;
    }
});

foreach ($issues as $issue) {
    // ...
}

$issues->first();   // one request
$issues->take(150); // two

$issues
    ->filter(fn(array $issue) => \str_contains($issue['title'], 'pagination'))
    ->map(fn(array $issue) => $issue['title'])
    ->take(3)       // nothing requested yet
;

\count($issues);          // 22202 - after requesting all 223 pages
$issues->take(20, 980);   // 11 requests - it skipped 980 items to get there

use Zenstruck\Collection\CallbackCollection;

$query = ['q' => 'repo:symfony/symfony is:issue'];

$issues = new CallbackCollection(
    function() use ($client, $query) {
        $page = 1;

        while ($items = $client->request('GET', 'https://api.github.com/search/issues', [
            'query' => $query + ['per_page' => 100, 'page' => $page++],
        ])->toArray()['items']) {
            yield from $items;
        }
    },
    fn() => $client->request('GET', 'https://api.github.com/search/issues', [
        'query' => $query + ['per_page' => 1],
    ])->toArray()['total_count'],
);

\count($issues);    // 22202 - one request
$issues->isEmpty(); // ...the same request

namespace App\GitHub;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Zenstruck\Collection;
use Zenstruck\Collection\IterableCollection;
use Zenstruck\Collection\LazyCollection;

/**
 * @implements Collection<array<string,mixed>,int>
 */
final class IssueSearch implements Collection
{
    /** @use IterableCollection<array<string,mixed>,int> */
    use IterableCollection;

    private const PER_PAGE = 100; // the API's maximum

    public function __construct(private HttpClientInterface $client, private string $query)
    {
    }

    public function getIterator(): \Traversable
    {
        foreach ($this->apiPages() as $items) {
            yield from $items;
        }
    }

    public function count(): int
    {
        return $this->request(['per_page' => 1])['total_count'];
    }

    public function take(int $limit, int $offset = 0): Collection
    {
        return new LazyCollection(function() use ($limit, $offset) {
            $skip = $offset % self::PER_PAGE;

            // start at the page the window begins in, instead of skipping from the front
            foreach ($this->apiPages(\intdiv($offset, self::PER_PAGE) + 1) as $items) {
                foreach (\array_slice($items, $skip) as $item) {
                    yield $item;

                    if (0 === --$limit) {
                        return;
                    }
                }

                $skip = 0;
            }
        });
    }

    /**
     * @return \Traversable<int,list<array<string,mixed>>>
     */
    private function apiPages(int $from = 1): \Traversable
    {
        while ($items = $this->request(['per_page' => self::PER_PAGE, 'page' => $from++])['items']) {
            yield $items;
        }
    }

    /**
     * @param array<string,mixed> $query
     *
     * @return array<string,mixed>
     */
    private function request(array $query): array
    {
        return $this->client->request('GET', 'https://api.github.com/search/issues', [
            'query' => $query + ['q' => $this->query],
        ])->toArray();
    }
}

$issues = new IssueSearch($client, 'repo:symfony/symfony is:issue');

\count($issues);        // 22202 - one request
$issues->take(20);      // one request
$issues->take(20, 980); // still one - it asks for the page the window is in