<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: delacry</title>
    <description>The latest articles on Forem by delacry (@delacry).</description>
    <link>https://forem.com/delacry</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3784524%2Fb4b9b2af-520a-459c-9a29-9712a5b4477e.jpeg</url>
      <title>Forem: delacry</title>
      <link>https://forem.com/delacry</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/delacry"/>
    <language>en</language>
    <item>
      <title>PHP 8.5's pipe operator and the array stdlib problem</title>
      <dc:creator>delacry</dc:creator>
      <pubDate>Thu, 30 Apr 2026 11:44:39 +0000</pubDate>
      <link>https://forem.com/delacry/php-85s-pipe-operator-hits-a-wall-on-array-code-5c7e</link>
      <guid>https://forem.com/delacry/php-85s-pipe-operator-hits-a-wall-on-array-code-5c7e</guid>
      <description>&lt;p&gt;PHP 8.5 shipped a pipe operator, from &lt;a href="https://wiki.php.net/rfc/pipe-operator-v3" rel="noopener noreferrer"&gt;Larry Garfield's RFC&lt;/a&gt; (approved 33-7). The marketing examples look great:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reads top to bottom, no nesting, types flow. For chains of single-input transformations, which is what the RFC was explicit about targeting, the operator does exactly what you'd want.&lt;/p&gt;

&lt;p&gt;The friction starts when you reach for it on PHP's array stdlib, which is where most day-to-day chaining happens. Most of what follows isn't really a flaw in the pipe operator itself. It's the stdlib's call-shape inconsistencies leaking through whatever composition mechanism you put on top of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The naive port
&lt;/h2&gt;

&lt;p&gt;A common form-handling task: clean user-submitted tags and sort a-z.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$cleanTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nv"&gt;$rawTags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cleanTags&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three nested calls plus a trailing &lt;code&gt;sort&lt;/code&gt;. It has to be its own statement because &lt;code&gt;sort&lt;/code&gt; returns &lt;code&gt;bool&lt;/code&gt; and mutates the array by reference. You read the nested part middle-out: &lt;code&gt;array_map&lt;/code&gt; runs first, then &lt;code&gt;array_filter&lt;/code&gt;, then &lt;code&gt;array_values&lt;/code&gt;. Eyes parse opposite to execution.&lt;/p&gt;

&lt;p&gt;The pipe operator should clean this up. Here's the rewrite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$cleanTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$rawTags&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better than the nested version. Reads top to bottom. But three things are happening here that the toy examples don't tell you about.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha 1: argument-order mismatch
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;array_map&lt;/code&gt; and &lt;code&gt;array_filter&lt;/code&gt; take their arguments in different orders.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// callable first&lt;/span&gt;
&lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;callable&lt;/span&gt; &lt;span class="nv"&gt;$callback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// array first&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipe operator passes the left value as the &lt;em&gt;first&lt;/em&gt; argument to whatever's on the right, so you can't first-class either function into a chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$rawTags&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// works, array is first arg&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$rawTags&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// broken, $rawTags lands as callback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To use &lt;code&gt;array_map&lt;/code&gt; in a pipe, wrap it in an arrow function to swap the argument order. In the rewrite, both &lt;code&gt;array_map&lt;/code&gt; and &lt;code&gt;array_filter&lt;/code&gt; need a wrapper. &lt;code&gt;array_map&lt;/code&gt; to swap arguments, &lt;code&gt;array_filter&lt;/code&gt; to inject the predicate. Only &lt;code&gt;array_values&lt;/code&gt; fits naturally because it's single-argument.&lt;/p&gt;

&lt;p&gt;The pipe operator doesn't paper over the stdlib's inconsistencies; it makes them more visible. Every chain across &lt;code&gt;array_map&lt;/code&gt; / &lt;code&gt;array_filter&lt;/code&gt; / &lt;code&gt;array_reduce&lt;/code&gt; ends up with this kind of glue in it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha 2: arrow functions need parentheses
&lt;/h2&gt;

&lt;p&gt;Look at the wrappers in the rewrite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parens around the arrow function are required, not stylistic. Without them, the arrow function "captures" everything to the end of the expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$rawTags&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser reads that as the arrow function returning everything from &lt;code&gt;array_map(...)&lt;/code&gt; through &lt;code&gt;|&amp;gt; array_values(...)&lt;/code&gt;. One stage, not two.&lt;/p&gt;

&lt;p&gt;This was the edge case the 2025-08-28 RFC errata pinned down. The rule: wrap arrow functions in parens any time they appear in a pipe chain. Forget once and the bug is silent.&lt;/p&gt;

&lt;p&gt;First-class callables (&lt;code&gt;trim(...)&lt;/code&gt;, &lt;code&gt;strtolower(...)&lt;/code&gt;) avoid the issue because they're a single token, with no expression body for the parser to grab. The moment your chain has any non-unary stdlib function, though, you're back to writing &lt;code&gt;(fn($x) =&amp;gt; ...)&lt;/code&gt; over and over.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha 3: by-reference functions don't compose
&lt;/h2&gt;

&lt;p&gt;That last step in the rewrite is uglier than the others for a reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$ts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can't be a one-liner arrow function, and it can't be a first-class callable. The RFC explicitly forbids first-class callables for any function whose first parameter is by-reference, which takes a long list of stdlib functions out of the pipe-able set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$rawTags&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// error: by-ref param&lt;/span&gt;
&lt;span class="nv"&gt;$rawTags&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same&lt;/span&gt;
&lt;span class="nv"&gt;$stack&lt;/span&gt; &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sort&lt;/code&gt;, &lt;code&gt;rsort&lt;/code&gt;, &lt;code&gt;usort&lt;/code&gt;, &lt;code&gt;ksort&lt;/code&gt;, &lt;code&gt;array_push&lt;/code&gt;, &lt;code&gt;array_pop&lt;/code&gt;, &lt;code&gt;array_shift&lt;/code&gt;, &lt;code&gt;array_unshift&lt;/code&gt;, &lt;code&gt;array_walk&lt;/code&gt;. All by-reference, all rejected. Most of the in-place array operations you'd reach for during a chain. The workaround is the full closure shown above: a function that mutates and returns. In practice you'll usually call &lt;code&gt;sort&lt;/code&gt; outside the pipe and feed the result in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the pipe operator actually shines
&lt;/h2&gt;

&lt;p&gt;For chains of unary stdlib functions, the operator is exactly what you'd want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/[^a-z0-9]+/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;gzencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;|&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Top to bottom, no argument-order gymnastics, the parens-around-arrow rule isn't a constant tax. String pipelines, encode/decode chains, math chains, hex round-trips. Anywhere the data is the first argument and the functions are unary, this is good code. &lt;code&gt;__invoke&lt;/code&gt; objects and instance methods compose cleanly too, which is probably the use case the operator was actually designed for.&lt;/p&gt;




&lt;h2&gt;
  
  
  A coherent collection API does this naturally
&lt;/h2&gt;

&lt;p&gt;The same tag-cleanup code with a collection library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$cleanTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rawTags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No wrappers, no parens-around-arrows, no &lt;code&gt;array_values&lt;/code&gt; plumbing, no by-ref dance. PHPStan carries &lt;code&gt;ImmutableList&amp;lt;string&amp;gt;&lt;/code&gt; all the way through. &lt;code&gt;map()&lt;/code&gt;, &lt;code&gt;filter()&lt;/code&gt;, and &lt;code&gt;sorted()&lt;/code&gt; are methods, so the data is implicit (it's &lt;code&gt;$this&lt;/code&gt;) and the callable is the first explicit argument every time.&lt;/p&gt;

&lt;p&gt;The example uses &lt;a href="https://noctud.dev" rel="noopener noreferrer"&gt;noctud/collection&lt;/a&gt;, which is what I work on. Other collection libraries (illuminate/collections, doctrine/collections, ramsey/collection) solve the call-shape problem the same way at the method level. The point isn't the specific library; it's that a coherent method-chain API sidesteps the inconsistencies the pipe operator inherits from the stdlib.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real fix would be native
&lt;/h2&gt;

&lt;p&gt;The deeper issue is the stdlib itself. PHP's stdlib was never shaped for chaining. &lt;code&gt;array_map&lt;/code&gt; and &lt;code&gt;array_filter&lt;/code&gt; taking arguments in different orders is a 1995 design that calcified before anyone thought about composition. The pipe operator works around the symptom. Native methods on arrays and strings would fix the cause.&lt;/p&gt;

&lt;p&gt;Nikita Popov's &lt;a href="https://github.com/nikic/scalar_objects" rel="noopener noreferrer"&gt;scalar_objects extension&lt;/a&gt;, from 2014, already showed what that could look like: &lt;code&gt;$str-&amp;gt;length()&lt;/code&gt;, &lt;code&gt;$arr-&amp;gt;map(...)&lt;/code&gt;, methods directly on the primitives. It worked, and it's been sitting there as a proof of concept for over a decade. The reason it never made it to core is that as an extension, every userland library could register its own method set on the built-in types, trading the current inconsistency for a different one with composer-install collisions on top.&lt;/p&gt;

&lt;p&gt;Doing it there means making the harder calls extensions get to dodge: which methods, what's the receiver, and the bigger question of whether &lt;code&gt;array&lt;/code&gt; is really one type or two (a list and a map) wearing the same hat. Most modern languages split them. PHP didn't, and userland libraries (noctud/collection included) only paper over that conflation from outside the language. If core ever takes a serious run at native methods on arrays and strings, plus the harder design call of splitting &lt;code&gt;array&lt;/code&gt;, that's the win the pipe operator alone can't deliver.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;PHP 8.5's pipe operator is a real improvement. Use it for unary chains: string normalization, encode/decode pipelines, math, hex round-trips, pipelines of &lt;code&gt;__invoke&lt;/code&gt; objects.&lt;/p&gt;

&lt;p&gt;For array operations (anything involving &lt;code&gt;array_map&lt;/code&gt;, &lt;code&gt;array_filter&lt;/code&gt;, or by-reference functions like &lt;code&gt;sort&lt;/code&gt; and &lt;code&gt;array_walk&lt;/code&gt;), the operator inherits all of PHP's stdlib inconsistencies and adds an arrow-function-paren footgun on top. More readable than the nested version, but the readability gain shrinks once you've added all the wrappers. If your code spends most of its time chaining array operations, a collection library remains the cleaner answer.&lt;/p&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>symfony</category>
      <category>webdev</category>
    </item>
    <item>
      <title>PHP collection libraries in 2026: doctrine, illuminate, loophp, noctud, ramsey</title>
      <dc:creator>delacry</dc:creator>
      <pubDate>Tue, 28 Apr 2026 14:20:11 +0000</pubDate>
      <link>https://forem.com/delacry/php-collection-libraries-in-2026-doctrine-illuminate-loophp-noctud-ramsey-4a12</link>
      <guid>https://forem.com/delacry/php-collection-libraries-in-2026-doctrine-illuminate-loophp-noctud-ramsey-4a12</guid>
      <description>&lt;p&gt;If you've written PHP for any length of time, you've fought the array. It's a hash table, a list, a tuple, and an associative store all crammed into one type, and it silently casts your keys to whatever it feels like. Every serious project eventually reaches for something better. There are a few reasonable places to reach.&lt;/p&gt;

&lt;p&gt;I wrote one of the five libraries this post covers (noctud/collection). I've tried to be fair, including pointing you at the right library when it isn't mine. Weigh that as you read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five contenders
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;doctrine/collections.&lt;/strong&gt; The collections layer underneath Doctrine ORM. Mature, stable, present as a transitive dependency in a huge chunk of the PHP ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;illuminate/collections.&lt;/strong&gt; Laravel's &lt;code&gt;Collection&lt;/code&gt; class. Probably the most-installed PHP utility class that exists. Around 140 chainable helper methods, fluent API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;loophp/collection.&lt;/strong&gt; Pol Dellaiera's lazy collection library, built around generators. FP-style, immutable, strong static analysis story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;noctud/collection.&lt;/strong&gt; Modern PHP 8.4+ library. Separate mutable/immutable types, full generics, key-preserving Maps. Currently v0.1.1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ramsey/collection.&lt;/strong&gt; Ben Ramsey's standalone typed collection library. No framework dependency, runtime type enforcement, well-known beyond the Symfony/Laravel split.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on shape: most PHP "collections" are really maps
&lt;/h2&gt;

&lt;p&gt;Before getting into the per-library breakdown, there's a structural distinction worth flagging that decides a lot of what follows.&lt;/p&gt;

&lt;p&gt;PHP's array is a weird shape. It's both a list (&lt;code&gt;[1, 2, 3]&lt;/code&gt;) and an associative map (&lt;code&gt;['a' =&amp;gt; 1, 'b' =&amp;gt; 2]&lt;/code&gt;) at the same time, depending on what keys end up in it. Most PHP "collection libraries" inherit this and end up as a single &lt;code&gt;Collection&lt;/code&gt; class that's really an &lt;code&gt;array&amp;lt;TKey, TValue&amp;gt;&lt;/code&gt; wrapper underneath. There's no separate type for "list of items" or "set of unique items" or "key-value pairs"; there's just one Collection that happens to have keys you can choose to ignore.&lt;/p&gt;

&lt;p&gt;Real collection design (the kind you see in Java, Kotlin, C#, Python, Rust) keeps these shapes separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;List&lt;/strong&gt;. Positional, indexed by 0-based int, duplicates allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set&lt;/strong&gt;. Unique elements, no positional access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Map&lt;/strong&gt;. Key-value pairs, keys are unique.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction isn't pedantic. It shapes the API in real ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Single-Collection style (the array-wrapper approach)&lt;/span&gt;
&lt;span class="nv"&gt;$col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$col&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// removes AT INDEX 1, leaving [0 =&amp;gt; 1, 2 =&amp;gt; 3, 3 =&amp;gt; 2]&lt;/span&gt;

&lt;span class="c1"&gt;// Distinct types&lt;/span&gt;
&lt;span class="nv"&gt;$list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$list&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// [1, 3, 2] - by position&lt;/span&gt;
&lt;span class="nv"&gt;$list&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// [1, 3, 2] - by value, removes first match&lt;/span&gt;

&lt;span class="nv"&gt;$set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$set&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;removeElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// {1, 3} - sets only do value-removal&lt;/span&gt;

&lt;span class="nv"&gt;$map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'b'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ['b' =&amp;gt; 2] - by key, the only sensible meaning here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a library only has one Collection type, it has to commit to one semantic for ambiguous operations and live with the confusion. Either &lt;code&gt;remove()&lt;/code&gt; removes by key (which surprises List users) or by value (which surprises Map users). Iteration is the same: a Map should yield &lt;code&gt;($value, $key)&lt;/code&gt; pairs, a Set should yield &lt;code&gt;$value&lt;/code&gt; only, a List should yield &lt;code&gt;($value, $index)&lt;/code&gt;. Single-Collection libraries tend to yield &lt;code&gt;($value, $key)&lt;/code&gt; everywhere because that's the array shape, and the Set/List meaning gets lost.&lt;/p&gt;

&lt;p&gt;Of the five libraries in this post, only ramsey/collection and noctud/collection are built around the real Set/List/Map separation. The other three are single-Collection types, varying in lazy-vs-eager and ergonomics but all sharing the array-wrapper shape underneath. That distinction is going to come up repeatedly in the per-library notes below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick feature matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;doctrine&lt;/th&gt;
&lt;th&gt;illuminate&lt;/th&gt;
&lt;th&gt;loophp&lt;/th&gt;
&lt;th&gt;noctud&lt;/th&gt;
&lt;th&gt;ramsey&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PHP min&lt;/td&gt;
&lt;td&gt;8.4 (3.x)&lt;/td&gt;
&lt;td&gt;8.3&lt;/td&gt;
&lt;td&gt;8.1&lt;/td&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;td&gt;8.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real &lt;code&gt;List&lt;/code&gt;/&lt;code&gt;Set&lt;/code&gt;/&lt;code&gt;Map&lt;/code&gt; types&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sub-collections compose (&lt;code&gt;keys&lt;/code&gt;/&lt;code&gt;values&lt;/code&gt; are real collections)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full generic propagation&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime type checks&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mutable variant&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immutable variant&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-string Map keys&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lazy by default&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change tracking&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indices shift on removal&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;✅ yes  ·  ⚠️ partial or with caveats (see per-library notes)  ·  ❌ no&lt;/p&gt;

&lt;p&gt;The ⚠️ cells specifically: noctud's runtime type checks only apply to &lt;code&gt;stringMapOf&lt;/code&gt;/&lt;code&gt;intMapOf&lt;/code&gt;. Doctrine's change tracking only fires when wired into the ORM. Illuminate keeps index gaps after &lt;code&gt;filter&lt;/code&gt; until you call &lt;code&gt;-&amp;gt;values()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A note on the doctrine row: 3.x is the default branch and requires PHP 8.4. Generics propagation is genuinely full on the modern API (&lt;code&gt;map&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;partition&lt;/code&gt;, &lt;code&gt;findFirst&lt;/code&gt;, &lt;code&gt;reduce&lt;/code&gt; all carry types through), with &lt;code&gt;slice()&lt;/code&gt; as the one exception that drops back to a plain &lt;code&gt;array&lt;/code&gt;. If you're stuck on doctrine 2.x for ORM compatibility reasons, the propagation story is weaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  doctrine/collections
&lt;/h2&gt;

&lt;p&gt;Around since the Doctrine 2 days, present as a transitive dependency in a huge chunk of PHP code on Packagist. The API is what you'd expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doctrine\Common\Collections\ArrayCollection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ArrayCollection&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$user1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user3&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nv"&gt;$first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$active&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$active&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thing nothing else here can replicate is the ORM tie-in. When you reassign &lt;code&gt;$user-&amp;gt;tags = new ArrayCollection([...])&lt;/code&gt;, Doctrine knows what was added and removed and writes the SQL to sync. Invisible if you're not using Doctrine, but it's why this library exists.&lt;/p&gt;

&lt;p&gt;Outside that use case, the limits show up fast. Single Collection type, no Set/List/Map split. No immutable variant. No real &lt;code&gt;Map&lt;/code&gt; with arbitrary keys (keys are constrained to &lt;code&gt;array-key&lt;/code&gt;, same as PHP arrays). The generics story on the 3.x branch is actually solid; &lt;code&gt;map&lt;/code&gt;/&lt;code&gt;filter&lt;/code&gt;/&lt;code&gt;partition&lt;/code&gt; carry types through cleanly, with &lt;code&gt;slice()&lt;/code&gt; as the one method that drops back to a plain array.&lt;/p&gt;

&lt;p&gt;The other rough edge is the &lt;code&gt;false&lt;/code&gt;-on-failure pattern. &lt;code&gt;first()&lt;/code&gt; and &lt;code&gt;last()&lt;/code&gt; return &lt;code&gt;T|false&lt;/code&gt; instead of &lt;code&gt;T|null&lt;/code&gt;, so they don't compose with &lt;code&gt;??&lt;/code&gt;. &lt;code&gt;findFirst()&lt;/code&gt; returns &lt;code&gt;T|null&lt;/code&gt; (correct). The inconsistency within the same interface is annoying, and the &lt;code&gt;false&lt;/code&gt; sentinel collides with collections that legitimately hold booleans. A coherent design has &lt;code&gt;first()&lt;/code&gt; throw on empty and &lt;code&gt;firstOrNull()&lt;/code&gt; return &lt;code&gt;null&lt;/code&gt; for &lt;code&gt;??&lt;/code&gt; chains; doctrine doesn't split it that way.&lt;/p&gt;

&lt;p&gt;If you're using Doctrine ORM, this is the answer and you already have it. If you're not, the rest of this post is more useful to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  illuminate/collections
&lt;/h2&gt;

&lt;p&gt;If you've written Laravel, you've used &lt;code&gt;Collection&lt;/code&gt;. It's effectively the standard utility library for PHP at this point. Filter, map, groupBy, partition, pluck, where, sortBy, take, plus 130-something more, all chainable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate\Support\Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$user1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user3&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$users&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sortBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'createdAt'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The good parts are real. Method chaining is smooth, the API surface is enormous, the docs are excellent, every Laravel package speaks Collection, and PhpStorm with Laravel Idea has solid inference. There's also a &lt;code&gt;LazyCollection&lt;/code&gt; for streaming over big datasets without loading the whole thing into memory.&lt;/p&gt;

&lt;p&gt;The structural limits are also real. Single Collection type, so the shape critique applies here directly. Underneath, a &lt;code&gt;Collection&lt;/code&gt; is &lt;code&gt;array&amp;lt;mixed, mixed&amp;gt;&lt;/code&gt;. The generic annotations (&lt;code&gt;Collection&amp;lt;TKey, TValue&amp;gt;&lt;/code&gt;) exist but PHPStan doesn't reliably propagate them through long chains. There's no real &lt;code&gt;Set&lt;/code&gt;, you call &lt;code&gt;-&amp;gt;unique()&lt;/code&gt;. No real &lt;code&gt;Map&lt;/code&gt; either; if you want non-string keys you're back to fighting PHP. No immutable variant; some methods return new instances, others mutate the original, and the type system doesn't tell you which is which. Filtering leaves index gaps until you remember to call &lt;code&gt;-&amp;gt;values()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's also the column-oriented magic to grapple with. &lt;code&gt;pluck('name')&lt;/code&gt;, &lt;code&gt;where('active', true)&lt;/code&gt;, &lt;code&gt;keyBy('id')&lt;/code&gt;, &lt;code&gt;sortBy('created_at')&lt;/code&gt;, &lt;code&gt;groupBy('category')&lt;/code&gt;: these all assume your items are arrays or objects with named columns, and they reach inside the items via string keys to read those columns. Powerful for the database-row case Laravel was built around. Awkward outside it, and the type system can't help you here; rename a property and all the &lt;code&gt;where('oldName', ...)&lt;/code&gt; calls keep compiling and silently break. It's the kind of API surface that makes Collection feel like a query builder for in-memory rows rather than a generic data structure.&lt;/p&gt;

&lt;p&gt;If you're in a Laravel project, this is the right answer. The framework includes it, the ecosystem speaks it, fighting that is a waste of energy. Outside Laravel, the dependency footprint is heavier than it should be and you're paying for an ecosystem you're not using.&lt;/p&gt;

&lt;h2&gt;
  
  
  loophp/collection
&lt;/h2&gt;

&lt;p&gt;Pol Dellaiera's collection library, built around lazy evaluation via PHP generators. The fluent API has roughly the surface area of Laravel's collections, but every operation defers until you actually consume the chain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;loophp\collection\Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;fromIterable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lazy-by-default model is the win here. Long chains of transformations don't materialize intermediate arrays; you get one walk through the data with all operations composed. For streaming inputs (file lines, paginated query results, anything where you don't want the whole thing in memory at once), this is the right model.&lt;/p&gt;

&lt;p&gt;Static analysis is a focus, the codebase is well-typed, and PHPStan can follow the chain types better than most. The library is actively maintained with a thoughtful design.&lt;/p&gt;

&lt;p&gt;The trade-offs are the usual lazy-evaluation ones: debugging is harder (the chain doesn't run until you ask for results), some operations require materialization implicitly, and the mental model is less friendly to people who haven't worked with generators or FP languages. It's also a single Collection type, so the shape critique applies; no separate Set/List/Map.&lt;/p&gt;

&lt;p&gt;If your code is heavy on data pipelines and the eager/lazy distinction is something you want to control explicitly, loophp/collection is a strong choice. For mostly-small in-memory collections, the lazy machinery is overhead you don't need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note on DusanKasan/Knapsack&lt;/strong&gt;, in case you've seen it mentioned in older PHP collection comparisons: it covers similar Clojure-shaped lazy territory but the last release was in 2017. It's effectively abandoned and shouldn't be picked for new work today.&lt;/p&gt;

&lt;h2&gt;
  
  
  noctud/collection
&lt;/h2&gt;

&lt;p&gt;I built this one. PHP 8.4+, takes the Kotlin and C# collection designs and adapts them to what PHP 8.4 can actually do well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;Noctud\Collection\listOf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;Noctud\Collection\mutableMapOf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$userArray&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$users&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'alice'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutableMapOf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$roles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// object as key, no Fatal error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things that don't show up in the others:&lt;/p&gt;

&lt;p&gt;Mutable and immutable are separate types (&lt;code&gt;MutableList&amp;lt;T&amp;gt;&lt;/code&gt; vs &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; etc.). Immutable mutations are annotated with &lt;code&gt;#[NoDiscard]&lt;/code&gt;, which becomes a real warning in PHP 8.5 if you forget to capture the result. Combined with the real &lt;code&gt;List&lt;/code&gt;/&lt;code&gt;Set&lt;/code&gt;/&lt;code&gt;Map&lt;/code&gt; split (see the shape section above), the type system carries a lot more information than in the array-wrapper libraries.&lt;/p&gt;

&lt;p&gt;Generics actually propagate. PHPStan level 9 clean across the codebase, and the types reach into your code so you're not narrowing &lt;code&gt;mixed&lt;/code&gt; returns to assert.&lt;/p&gt;

&lt;p&gt;Maps preserve key types. &lt;code&gt;'1'&lt;/code&gt; stays a string, &lt;code&gt;true&lt;/code&gt; stays a bool, objects work as keys via &lt;code&gt;spl_object_id&lt;/code&gt; or your own &lt;code&gt;Hashable&lt;/code&gt; implementation. There are also &lt;code&gt;stringMapOf()&lt;/code&gt; and &lt;code&gt;intMapOf()&lt;/code&gt; variants if you want a single key type enforced at construction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$map-&amp;gt;keys&lt;/code&gt;, &lt;code&gt;$map-&amp;gt;values&lt;/code&gt;, and &lt;code&gt;$map-&amp;gt;entries&lt;/code&gt; are real &lt;code&gt;Set&lt;/code&gt; and &lt;code&gt;List&lt;/code&gt; instances backed by the same store. Not arrays, not copies. Full collection API on each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'b'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ImmutableSet {'a', 'b', 'c'}&lt;/span&gt;
&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 'a'&lt;/span&gt;
&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;joinToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'a, b, c'&lt;/span&gt;

&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 6&lt;/span&gt;
&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;

&lt;span class="nv"&gt;$map&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// MapEntry('a', 1)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In ramsey, doctrine, illuminate, and loophp, &lt;code&gt;$map-&amp;gt;keys()&lt;/code&gt; (or equivalent) is a plain PHP &lt;code&gt;array&lt;/code&gt; and you'd be wrapping it back into a collection by hand to chain anything.&lt;/p&gt;

&lt;p&gt;Lazy initialization uses PHP 8.4's actual lazy-objects feature, not a wrapper class. Pass a closure to any factory and materialization waits until you read.&lt;/p&gt;

&lt;p&gt;Indices shift correctly on removal from indexed lists, which solves the ramsey #133 case.&lt;/p&gt;

&lt;p&gt;What's not great about it: PHP 8.4+ only, so if you're on 8.2 or 8.3 you can't use it. v0.1.1 right now, so it hasn't seen the production hours that doctrine and illuminate have. No third-party plugins beyond the official PhpStorm one, no Stack Overflow corpus to search; you'll be reading the docs and the source. I maintain it solo on two days a week, so feature work moves slower than at the bigger libraries.&lt;/p&gt;

&lt;p&gt;If you're on PHP 8.4+ and want the best static-analysis story available, plus immutability, key preservation, and change tracking, it's the right answer. If not, one of the others probably fits better.&lt;/p&gt;

&lt;h2&gt;
  
  
  ramsey/collection
&lt;/h2&gt;

&lt;p&gt;Focused, framework-agnostic, built around runtime type enforcement. Has separate &lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;List&lt;/code&gt;, and &lt;code&gt;Map&lt;/code&gt; types rather than a single Collection class, which puts it on the same architectural side as noctud. The constructor takes a type string and the collection rejects anything that doesn't match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Ramsey\Collection\Collection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// OK&lt;/span&gt;
&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// InvalidArgumentException&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also &lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Queue&lt;/code&gt;, &lt;code&gt;Stack&lt;/code&gt;, and a few specialized variants. PHPDoc generics are well-developed; &lt;code&gt;Collection&amp;lt;int, User&amp;gt;&lt;/code&gt; propagates through most operations.&lt;/p&gt;

&lt;p&gt;The thing that bites people eventually is &lt;a href="https://github.com/ramsey/collection/issues/133" rel="noopener noreferrer"&gt;issue #133&lt;/a&gt;: when you remove an element from an indexed collection, the remaining indices don't shift down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'b'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$col&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$col&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// [0 =&amp;gt; 'a', 2 =&amp;gt; 'c']&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can call &lt;code&gt;array_values($col-&amp;gt;toArray())&lt;/code&gt; and re-wrap, but at that point you're working around the library, not with it. Issue's been open since 2020.&lt;/p&gt;

&lt;p&gt;The API surface is intentionally minimal, and that's where it shows. &lt;code&gt;MapInterface&lt;/code&gt; has 10 methods. Sub-collections don't compose: &lt;code&gt;$map-&amp;gt;keys()&lt;/code&gt; returns a plain &lt;code&gt;list&amp;lt;K&amp;gt;&lt;/code&gt;, not a &lt;code&gt;Set&amp;lt;K&amp;gt;&lt;/code&gt;. There's no &lt;code&gt;values()&lt;/code&gt; or &lt;code&gt;entries()&lt;/code&gt; method on the Map interface; values come out only via &lt;code&gt;toArray()&lt;/code&gt; from the parent. Keys are constrained to PHP's &lt;code&gt;array-key&lt;/code&gt; (int or string), so no object keys, no preserved bool/float/numeric-string keys. The type-enforcement story is good. The type-composition story stops at the first hop. If you want to chain &lt;code&gt;$map-&amp;gt;keys-&amp;gt;filter(...)-&amp;gt;sorted()&lt;/code&gt; the way Java or Kotlin let you, you're going to wrap arrays back into collections by hand a lot.&lt;/p&gt;

&lt;p&gt;No immutable variant either, and runtime checks have a measurable cost on hot paths.&lt;/p&gt;

&lt;p&gt;If you want runtime type enforcement, framework-free, on a project that's not on PHP 8.4 yet, this is still your library. The runtime checks earn their keep when you're consuming data from sources you don't fully trust. Just go in expecting a thin layer rather than a full collections framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Already on Doctrine ORM:&lt;/strong&gt; doctrine/collections, no contest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laravel project:&lt;/strong&gt; illuminate/collections, don't fight the framework.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy data pipelines, lazy-by-default ergonomics, framework-free:&lt;/strong&gt; loophp/collection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework-free, PHP 8.1 to 8.3, want runtime type checks:&lt;/strong&gt; ramsey/collection. Expect a minimal API surface (sub-collections don't compose) and &lt;a href="https://github.com/ramsey/collection/issues/133" rel="noopener noreferrer"&gt;#133&lt;/a&gt; for indexed removals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework-free, PHP 8.4+, prioritize static analysis and the real Set/List/Map split:&lt;/strong&gt; noctud/collection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These libraries don't really compete the way the framing of this post implies. Each one solved a specific problem for the people who built it. Doctrine needed an ORM-aware collection layer. Laravel needed a fluent utility class. loophp wanted Clojure-style lazy pipelines. Ramsey wanted type-safe collections without a framework dependency. I wanted a PHP 8.4-shaped library with real Set/List/Map separation that wasn't there.&lt;/p&gt;

&lt;p&gt;The shape question is the one I'd think about first. If your code lives mostly in the array-wrapper world (Laravel, Doctrine, FP pipelines), the single-Collection libraries fit naturally. If you keep finding yourself wanting a Set that enforces uniqueness or a Map that respects key types, you're going to want one of the libraries that takes those distinctions seriously, and there are exactly two: ramsey/collection (PHP 8.1+, runtime checks) and noctud/collection (PHP 8.4+, static-analysis-first).&lt;/p&gt;

&lt;p&gt;If you're starting a greenfield PHP 8.4+ project today and your default is to install whatever you used last time out of habit, look around first. The right collection library saves you a lot of &lt;code&gt;array_values(array_filter(...))&lt;/code&gt; over the lifetime of a codebase.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Read more:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://noctud.dev" rel="noopener noreferrer"&gt;noctud/collection docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.doctrine-project.org/projects/collections.html" rel="noopener noreferrer"&gt;doctrine/collections docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://laravel.com/docs/collections" rel="noopener noreferrer"&gt;Laravel collections docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/loophp/collection" rel="noopener noreferrer"&gt;loophp/collection on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ramsey/collection" rel="noopener noreferrer"&gt;ramsey/collection docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>laravel</category>
      <category>symfony</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Type-safe collections in PHP 8.4: what I wish arrays had</title>
      <dc:creator>delacry</dc:creator>
      <pubDate>Sat, 25 Apr 2026 19:07:42 +0000</pubDate>
      <link>https://forem.com/delacry/type-safe-collections-in-php-84-what-i-wish-arrays-had-4gof</link>
      <guid>https://forem.com/delacry/type-safe-collections-in-php-84-what-i-wish-arrays-had-4gof</guid>
      <description>&lt;p&gt;You know the feeling. You're three callbacks deep in &lt;code&gt;array_map&lt;/code&gt;, you tack on &lt;code&gt;array_filter&lt;/code&gt;, then &lt;code&gt;array_values&lt;/code&gt; to fix the keys, and PhpStorm gives up and types everything as &lt;code&gt;array&lt;/code&gt;. PHPStan was useful five lines ago. Now it's just nodding politely.&lt;/p&gt;

&lt;p&gt;I spent six months of weekends building a library to fix this. It's called &lt;code&gt;noctud/collection&lt;/code&gt;, it's PHP 8.4+ only, and this post is about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The everyday pain
&lt;/h2&gt;

&lt;p&gt;Here's a scene that probably looks familiar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$activeAdmins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three problems packed into one expression.&lt;/p&gt;

&lt;p&gt;Read order fights execution order. Your eyes hit &lt;code&gt;array_values&lt;/code&gt; first, but it runs last. You parse the code in the opposite direction it executes, every time, forever.&lt;/p&gt;

&lt;p&gt;Types collapse. &lt;code&gt;array_filter&lt;/code&gt; returns &lt;code&gt;array&amp;lt;int, User&amp;gt;&lt;/code&gt;. &lt;code&gt;array_values&lt;/code&gt; returns &lt;code&gt;array&amp;lt;int, User&amp;gt;&lt;/code&gt;. Try the same thing with a &lt;code&gt;User|Customer&lt;/code&gt; union and a couple of generic helpers in the chain, and PHPStan starts shrugging.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;array_values&lt;/code&gt; is there to plug a hole in PHP itself. Filtering leaves index gaps. You forget the call once and your JSON output suddenly serializes as an object instead of an array because your indices went &lt;code&gt;0, 2, 5&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And then there are keys. PHP arrays don't really have keys, they have a sad approximation of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nb"&gt;var_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// [0 =&amp;gt; int(1)] - your "1" is now an int&lt;/span&gt;

&lt;span class="nv"&gt;$b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'x'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'y'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 1 - true and 1 collide&lt;/span&gt;

&lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$someUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Fatal error: Illegal offset type&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can't type-annotate these problems away. &lt;code&gt;array&amp;lt;string, User&amp;gt;&lt;/code&gt; is a comfortable lie. PHP will happily put int keys in there and PHPStan can only believe whatever you wrote in the docblock.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built instead
&lt;/h2&gt;

&lt;p&gt;Three real types: &lt;code&gt;List&lt;/code&gt;, &lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;. Each one in mutable, immutable, and lazy flavors. Full generics that flow through every method. Implementations are hidden behind interfaces, so swapping internals later is free.&lt;/p&gt;

&lt;p&gt;Here's the same code, rewritten:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$activeAdmins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isAdmin&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. Top to bottom in reading order, no plumbing call to fix indices, and PHPStan keeps &lt;code&gt;ImmutableList&amp;lt;User&amp;gt;&lt;/code&gt; all the way through. If &lt;code&gt;map()&lt;/code&gt; had narrowed the element type, you'd get that propagated too.&lt;/p&gt;

&lt;p&gt;Maps stop lying about keys.&lt;/p&gt;

&lt;p&gt;Objects work, no Fatal error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Jesse'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$roles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutableMapOf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$roles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$roles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// 'admin'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default object hashing uses &lt;code&gt;spl_object_id&lt;/code&gt;. Implement &lt;code&gt;Hashable&lt;/code&gt; on your own classes when you want value-based identity instead of reference identity, which is what you usually want for value objects.&lt;/p&gt;

&lt;p&gt;For mixed key types, you can't go through an array literal at all (PHP casts at the literal level, before any function sees it). &lt;code&gt;mapOfPairs&lt;/code&gt; sidesteps that by taking pairs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapOfPairs&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'enabled'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'one'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'string-one'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$flags&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 3 - PHP arrays would have collapsed all three&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you specifically want a &lt;code&gt;Map&amp;lt;string, V&amp;gt;&lt;/code&gt; and you're getting data from somewhere PHP has already mangled (a request, a DB row, a JSON decode), &lt;code&gt;stringMapOf()&lt;/code&gt; is the recovery path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stringMapOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'enabled'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ImmutableSet {'1'} - cast back to string at construction&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a matching &lt;code&gt;intMapOf()&lt;/code&gt; that goes the other direction and rejects anything that isn't an int. The factories enforce the key type at construction, so the analyzer and the runtime end up agreeing on &lt;code&gt;array&amp;lt;string, V&amp;gt;&lt;/code&gt; (or &lt;code&gt;array&amp;lt;int, V&amp;gt;&lt;/code&gt;) without you having to lie in a docblock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things you probably don't get from other libs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://noctud.dev/collection/mutability" rel="noopener noreferrer"&gt;Mutable and immutable are separate types, not flags.&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;MutableList&amp;lt;T&amp;gt;::add()&lt;/code&gt; returns &lt;code&gt;$this&lt;/code&gt;. &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;::add()&lt;/code&gt; returns a new instance and is annotated with &lt;code&gt;#[NoDiscard]&lt;/code&gt;, which becomes a real warning in PHP 8.5. No more silently throwing your "added" element into the void.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://noctud.dev/collection/design#views-not-copies" rel="noopener noreferrer"&gt;Map views are live collections.&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;$map-&amp;gt;keys&lt;/code&gt;, &lt;code&gt;$map-&amp;gt;values&lt;/code&gt;, and &lt;code&gt;$map-&amp;gt;entries&lt;/code&gt; are real &lt;code&gt;Set&lt;/code&gt; and &lt;code&gt;List&lt;/code&gt; instances backed by the same underlying store. They share memory and they have the full collection API. So &lt;code&gt;$map-&amp;gt;values-&amp;gt;sum()&lt;/code&gt; and &lt;code&gt;$map-&amp;gt;keys-&amp;gt;sorted()&lt;/code&gt; just work, no copying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://noctud.dev/collection/mutability" rel="noopener noreferrer"&gt;Change tracking, only when you actually want it.&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mutableSetOf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'kotlin'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$tags&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tracked&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'php'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// false - 'php' was already in the set&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote this for cache invalidation logic and got tired of writing the "did this actually do anything" check by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://noctud.dev/collection/lazy-init" rel="noopener noreferrer"&gt;Lazy initialization via PHP 8.4 lazy objects.&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Pass a closure to any factory and the data is materialized only when first accessed. Copy-on-write between mutable and immutable variants is virtually free for the common case where you don't mutate after converting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://plugins.jetbrains.com/plugin/30173-noctud" rel="noopener noreferrer"&gt;A small PhpStorm plugin&lt;/a&gt;&lt;/strong&gt; that fixes a couple of generic-inference quirks the IDE has with callbacks and &lt;code&gt;__invoke&lt;/code&gt;. A few of the bugs I &lt;a href="https://youtrack.jetbrains.com/issue/WI-82721/Multiple-bugs-when-using-static-return-type-on-Trait" rel="noopener noreferrer"&gt;reported upstream&lt;/a&gt; while I was at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the design comes from
&lt;/h2&gt;

&lt;p&gt;If the API feels familiar after using Kotlin or modern C#, that's intentional. Kotlin got the mutable/immutable split right, the read-only interfaces right, and the chained pipeline ergonomics right. Java laid down the foundational List/Set/Map vocabulary decades earlier. I borrowed from both, the &lt;a href="https://noctud.dev/collection/faq" rel="noopener noreferrer"&gt;FAQ&lt;/a&gt; walks through the specific differences if you want them.&lt;/p&gt;

&lt;p&gt;PHPStan level 9 and Psalm strict, both clean. The generics carry through into your code, so your call chains stay typed end to end with no &lt;code&gt;mixed&lt;/code&gt; returns to narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require noctud/collection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default pin (&lt;code&gt;^0.1.1&lt;/code&gt;) keeps you on 0.1.x patches only. BC breaks ship as 0.2 and composer won't auto-install them, so locking this way is safe through the 0.x cycle.&lt;/p&gt;

&lt;p&gt;Docs and examples: &lt;a href="https://noctud.dev" rel="noopener noreferrer"&gt;https://noctud.dev&lt;/a&gt;&lt;br&gt;
GitHub: &lt;a href="https://github.com/noctud/collection" rel="noopener noreferrer"&gt;https://github.com/noctud/collection&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'm planning a few 0.x releases through 2026 before locking the API for 1.0. Big remaining work is a &lt;code&gt;Sequence&lt;/code&gt; type for lazy intermediate operations (similar to Kotlin sequences or Java streams), and a tests refactor.&lt;/p&gt;

&lt;p&gt;If you try it on something real and the API gets in your way, I want to hear about it. Right now is when feedback actually shapes 1.0.&lt;/p&gt;

</description>
      <category>php</category>
      <category>symfony</category>
      <category>laravel</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
