Skip to content

Instantly share code, notes, and snippets.

@brunogaspar
Last active September 15, 2024 18:42
Show Gist options
  • Save brunogaspar/154fb2f99a7f83003ef35fd4b5655935 to your computer and use it in GitHub Desktop.
Save brunogaspar/154fb2f99a7f83003ef35fd4b5655935 to your computer and use it in GitHub Desktop.
Recursive Laravel Collection Macros

What?

If a nested array is passed into a Laravel Collection, by default these will be threaded as normal arrays.

However, that's not always the ideal case and it would be nice if we could have nested collections in a cleaner way.

This is where this macro comes in handy.

Setup

Register this macro for example on the boot method of your app\Providers\AppServiceProvider.php file:

\Illuminate\Support\Collection::macro('recursive', function () {
    return $this->map(function ($value) {
        if (is_array($value) || is_object($value)) {
            return collect($value)->recursive();
        }

        return $value;
    });
});

Note: Tested on Laravel 5.5 and 5.6!

How

Usage is quite simple:

$data = [
    [
        'name' => 'John Doe',
        'emails' => [
            'john@doe.com',
            'john.doe@example.com',
        ],
        'contacts' => [
            [
                'name' => 'Richard Tea',
                'emails' => [
                    'richard.tea@example.com',
                ],
            ],
            [
                'name' => 'Fergus Douchebag', // Ya, this was randomly generated for me :)
                'emails' => [
                    'fergus@douchebag.com',
                ],
            ],
        ],
    ],
];

$collection = collect($data)->recursive();
@Mombuyish
Copy link

Thanks!

@brunogaspar
Copy link
Author

Hey peeps!

Since i've saw a bit of "demand" for this helper/macro to be in a more installable way and considering Spatie refused a few pull requests to add this macro, i've decided to create my own package.

https://github.com/werxe/laravel-collection-macros

Feel free to use it and suggest new macros, i'm sure i'll be adding a few more.

Thanks!

@jonaslm
Copy link

jonaslm commented Oct 17, 2019

We wanted something similar, but for it to leave standard objects alone (because $object->foo is nicer than $object->get('foo') or $object['foo']). We also rewrote it as a helper method instead of a Collection macro, since it's more like the collect() helper method than something you do for an existing Collection.

If anyone else has the same needs, here you go:

function recursivelyCollect($item) {
	if (is_array($item)) {
		return recursivelyCollect(collect($item));
	} elseif ($item instanceof Collection) {
		$item->transform(static function ($collection) {
			return recursivelyCollect($collection);
		});
	} elseif (is_object($item)) {
		foreach ($item as $key => &$val) {
			$item->{$key} = recursivelyCollect($val);
		}
	}
	return $item;
}

A tinker example based on the one given in the original gist:

>>> $data
=> [
     {#3989
       +"name": "John Doe",
       +"emails": [
         "john@doe.com",
         "john.doe@example.com",
       ],
       +"contacts": [
         {#3992
           +"name": "Richard Tea",
           +"emails": [
             "richard.tea@example.com",
           ],
         },
         {#3990
           +"name": "Fergus Douchebag",
           +"emails": [
             "fergus@douchebag.com",
           ],
         },
       ],
     },
   ]
>>> recursivelyCollect($data);
=> Illuminate\Support\Collection {#3942
     all: [
       {#3989
         +"name": "John Doe",
         +"emails": Illuminate\Support\Collection {#3975
           all: [
             "john@doe.com",
             "john.doe@example.com",
           ],
         },
         +"contacts": Illuminate\Support\Collection {#3971
           all: [
             {#3992
               +"name": "Richard Tea",
               +"emails": Illuminate\Support\Collection {#3974
                 all: [
                   "richard.tea@example.com",
                 ],
               },
             },
             {#3990
               +"name": "Fergus Douchebag",
               +"emails": Illuminate\Support\Collection {#3972
                 all: [
                   "fergus@douchebag.com",
                 ],
               },
             },
           ],
         },
       },
     ],
   }

@lorisleiva
Copy link

Thanks, that was very helpful! 👍

I've tweaked the macro so that it wraps the $value using the lowest inheritance class.

Collection::macro('recursive', function () {
    return $this->map(function ($value) {
        return is_array($value) || is_object($value)
            ? (new static($value))->recursive()     // <- new static() instead of collect()
            : $value;
    });
});

For example, if you extend the Collection class like this:

class Knowledge extends Collection
{
    // ...
}

Then you can recursively wrap a nested array like that:

Knowledge::wrap($array)->recursive();

And now all nested collections will be instances of Knowledge.

@brunogaspar
Copy link
Author

No problem, glad you found it helpful!

@amirasyraf
Copy link

Thanks a lot for this.

@wivaku
Copy link

wivaku commented Nov 29, 2020

Nice! Is it possible to get combination of the 1) the original macro, 2) @lorisleiva 's tweak and 3) the modification by @jonaslm ?

This so I can use e.g.

$collection = collect($data)->recursive();
$collection->first()->name; // John Doe

(instead of the original macro: $collection->first()->get('name'))

@Tantalump
Copy link

@wivaku Can you share your code? Thanks.

@joserick
Copy link

A thousand years later... Requested by @wivaku:

Combination of the 1) the original macro @brunogaspar, 2) @lorisleiva 's tweak and 3) the modification by @jonaslm.

Collection::macro('recursive', function () {
    return $this->whenNotEmpty($recursive = function ($item) use (&$recursive) {
        if (is_array($item)) {
            return $recursive(new static($item));
        } elseif ($item instanceof Collection) {
            $item->transform(static function ($collection, $key) use ($recursive, $item) {
                return $item->{$key} = $recursive($collection);
            });
        } elseif (is_object($item)) {
            foreach ($item as $key => &$val) {
                $item->{$key} = $recursive($val);
            }
        }
        return $item;
    });
});
$collection = collect($data)->recursive();
$collection->first()->name; // John Doe

If anyone has any ideas on how to reduce it (less code) I would appreciate it.

@wivaku @Tantalump

@wivaku
Copy link

wivaku commented May 20, 2021

Excellent, thanks @joserick !

@77media-creations
Copy link

@joserick Nice, this works in laravel 8.

@dani0332
Copy link

dani0332 commented Sep 25, 2021

Resolved

Should have added \Illuminate\Support\Collection at $item instanceof Collection


@joserick Strangely though It doesn't work for me. But the original macro works/

$data = [
      [
          'name' => 'John Doe',
          'emails' => [
              'john@doe.com',
              'john.doe@example.com',
          ],
          'contacts' => [
              [
                  'name' => 'Richard Tea',
                  'emails' => [
                      'richard.tea@example.com',
                  ],
              ],
              [
                  'name' => 'Fergus Douchebag', // Ya, this was randomly generated for me :)
                  'emails' => [
                      'fergus@douchebag.com',
                  ],
              ],
          ],
      ],
  ];
  $collection = collect($data)->recursive();
  dd($collection->first()->name);

And the macro is

        \Illuminate\Support\Collection::macro('recursive', function () {
            return $this->whenNotEmpty($recursive = function ($item) use (&$recursive) {
                if (is_array($item)) {
                    return $recursive(new static($item));
                } elseif ($item instanceof Collection) { // <-- Should have added \Illuminate\Support\Collection
                    $item->transform(static function ($collection, $key) use ($recursive, $item) {
                        return $item->{$key} = $recursive($collection);
                    });
                } elseif (is_object($item)) {
                    foreach ($item as $key => &$val) {
                        $item->{$key} = $recursive($val);
                    }
                }
                return $item;
            });
        });

on dd($collection->first()); means its making it an array

array:3 [
  "name" => "John Doe"
  "emails" => array:2 [
    0 => "john@doe.com"
    1 => "john.doe@example.com"
  ]
  "contacts" => array:2 [
    0 => array:2 [
      "name" => "Richard Tea"
      "emails" => array:1 [
        0 => "richard.tea@example.com"
      ]
    ]
    1 => array:2 [
      "name" => "Fergus Douchebag"
      "emails" => array:1 [
        0 => "fergus@douchebag.com"
      ]
    ]
  ]
]

@PascalHesselink
Copy link

Collect Recursive with depth:

\Illuminate\Support\Collection::macro('recursive', function ($depth = null, $currentLayer = 1) {
    return $this->map(function ($value) use ($depth, $currentLayer) {
        if ((isset($depth) && $depth <= $currentLayer) || !(is_array($value) || is_object($value))) return $value;

        return collect($value)->recursive($depth, ($currentLayer + 1));
    });
});

@brunogaspar
Copy link
Author

@PascalHesselink Pretty cool! What was the main use case for you?

@PascalHesselink
Copy link

@brunogaspar I needed to connect/compare some values by filtering and remapping them. But it's kinda unnecessary to apply 30 layers deep a recursive collection for me. So that's why I created a depth function 😁

@marcomessa
Copy link

@brunogaspar thank you for sharing!!

@bhaidar
Copy link

bhaidar commented Dec 21, 2022

Hey @brunogaspar, I'm trying to understand how this recursive macro works internally.
When you call recursive() on a collection, the first layer of arrays gets converted to collections. The inner layers get converted when for example, mapping or printing (toArray) the entire collection.

Maybe I'm missing something here.

When a value is an array or object, you return collect($value)->recursive(). This doesn't run right away, correct?


Oh I just got it! When you return the collect($value)->recursive() it actually runs the recursive() function on the $value. The 4value gets converted to a collection and all its children if any an array, will also get converted to a collection

Thanks

@joserick
Copy link

I hope this new function can help you in something @bhaidar .

Combination of "Collect Recursive with depth" and "Access collect items as properties" (improved*)

// Add a new method called "recursive" to the Collection class.
\Illuminate\Support\Collection::macro('recursive', function (int $depth = null) {
    // Use the map method to iterate over the items in the collection.
    return $this->map(function ($item) use ($depth) {
        // If the depth is 0 or the item is not a collection, array, or object, return the item as-is.
        if (($depth === 0) || !($item instanceof \Illuminate\Support\Collection || is_array($item) || is_object($item))) {
            return $item;
        }

        // Create a new anonymous class that extends the Collection class and overrides the __get and __set magic methods.
        // To be able to access the collection items as if they were properties of a object.
        return (new class(new static($item)) extends \Illuminate\Support\Collection {
            public function __get($key) { return $this->get($key); }
            public function __set($key, $value) { $this->put($key, $value); }
        })->recursive($depth - 1); // Apply the "recursive" method to the new Collection instance.
    });
});

Access item "name" as a property:

$collection = collect($data)->recursive();
$collection->first()->name; // John Doe

@cyppe
Copy link

cyppe commented Jan 7, 2024

joserick That macro does not work at all for me. At least not in Laravel 10.

No array key, even first level, is accessible as property. But I love the idea.

$product = collect($product)->recursive();
dump($product);
dd($product->product_key);

Output:
Illuminate\Support\Collection {#268554
  #items: array:88 [
    "product_key" => "p-46844-se"
     ....

And exception on the attempt to access $product->product_key:

Property [product_key] does not exist on this collection instance.

Do you maybe have some idea why it does not work? Or I maybe misunderstood the use case.

@joserick
Copy link

joserick commented Jan 8, 2024

@cyppe Interesting, if you like, send me an example of your $data that you tried to use and since it has been 1 year since I did this, something may have changed in Laravel.

@Rikaelus
Copy link

Rikaelus commented May 1, 2024

I found this while researching a similar need and this is really good, but I ended up taking a slightly different approach that might be appealing to others who find their way here.

I opted for a helper function for IDE code prediction purposes but the real difference is trying to keep close to the core Collection transforming philosophy (which can collectivize various types) but then making most of it toggleable. I went ahead and threw in a depth option, too, after seeing @PascalHesselink's contribution here.

if (!function_exists('rCollect')){
    /**
     * Recursively convert all arrays to collections
     *
     * @param  array  $value
     * @param  bool  $array  True to convert array descendants
     * @param  bool  $enumerable  True to convert Enumerable descendants
     * @param  bool  $arrayable  True to convert Arrayable descendants
     * @param  bool  $traversable  True to convert Traversable descendants
     * @param  bool  $jsonable  True to convert Jsonable descendants
     * @param  bool  $jsonSerializable  True to convert JsonSerializable descendants
     * @param  bool  $unitEnum  True to convert UnitEnum descendants
     * @param  bool  $all  True to convert all eligible descendants
     * @param  int|null  $depth  Levels of children to traverse into
     * @param  int  $currentLayer  (do not set; used for internal depth tracking)
     * @return Collection
     */
    function rCollect(
        mixed $value = [],
        bool $array = true,
        bool $enumerable = false,
        bool $arrayable = false,
        bool $traversable = false,
        bool $jsonable = false,
        bool $jsonSerializable = false,
        bool $unitEnum = false,
        bool $all = false,
        ?int $depth = null,
        int $currentLayer = 0,
    ): Collection
    {
        // Because func_get_args omits defaults and ReflectionFunction is too bulky
        $args = [$array,$enumerable,$arrayable,$traversable,$jsonable,$jsonSerializable,$unitEnum,$all,$depth,$currentLayer+1];
        return collect($value)
            ->when(
                $depth === null || $currentLayer < $depth,
                fn(Collection $c) => $c->map(
                    fn($child) =>
                        (is_array($child) && ($all || $array)) ||
                        ($child instanceof Enumerable && ($all || $enumerable)) ||
                        ($child instanceof Arrayable && ($all || $arrayable)) ||
                        ($child instanceof Traversable && ($all || $traversable)) ||
                        ($child instanceof Jsonable && ($all || $jsonable)) ||
                        ($child instanceof JsonSerializable && ($all || $jsonSerializable)) ||
                        ($child instanceof UnitEnum && ($all || $unitEnum)) ?
                            rCollect($child, ...$args) :
                            $child
                )
            );
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment