Refactoring with Laravel Collection Macros: The Complete Guide

· 3 min read

Refactoring with Laravel Collection Macros

Laravel Collections are arguably the best feature of the framework. They transform messy array manipulation into fluent, readable chains. But as your application grows, you often find yourself repeating the same map, filter, and reduce chains in multiple places.

You could create a Helper class. You could put it in a Service. But the most "Laravel way" is to teach the Collection class new tricks using Macros.

What is a Macro?

The Macroable trait (used by Collections, Request, Response, and many others) allows you to register custom methods at runtime. Essentially, you can "monkey patch" the framework's core classes safely.

Step 1: Cleaning up AppServiceProvider

Tutorials often tell you to put macros in AppServiceProvider::boot(). In a real project, this gets messy fast.

Create a dedicated provider:

php artisan make:provider CollectionMacroServiceProvider

Register it in bootstrap/providers.php (or config/app.php).

// app/Providers/CollectionMacroServiceProvider.php

use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;

class CollectionMacroServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Define your macros here
        Collection::macro('toUpper', function () {
            return $this->map(fn ($value) => strtoupper($value));
        });
    }
}

Real-World Examples

1. The "Standardize Keys" Macro

APIs often return messy keys (First_Name, last-name). Let's fix them to snake_case.

Collection::macro('standardizeKeys', function () {
    return $this->mapWithKeys(function ($value, $key) {
        return [Str::snake($key) => $value];
    });
});

// Usage
$data = collect(['First_Name' => 'John', 'user-ID' => 1]);
$data->standardizeKeys(); // ['first_name' => 'John', 'user_id' => 1]

2. The "If Empty" Fallback

Sometimes you want to return a default if the collection is empty, but you want to stay in the chain.

Collection::macro('ifEmpty', function (callable $callback) {
    if ($this->isEmpty()) {
        $callback($this);
    }
    return $this;
});

3. Complex Business Logic: Cartesian Products

Macros are perfect for complex math that you don't want to copy-paste.

Collection::macro('cartesianProduct', function ($collection) {
    return $this->flatMap(function ($a) use ($collection) {
        return $collection->map(function ($b) use ($a) {
            return [$a, $b];
        });
    });
});

Organizing with Mixins

If you have 20 macros, one file is still too big. Use Mixins. A Mixin is a class where methods return Closures.

// app/Macros/CollectionMixins.php

class CollectionMixins
{
    public function paginate()
    {
        return function ($perPage = 15, $page = null, $options = []) {
            $page = $page ?: (Paginator::resolveCurrentPage() ?: 1);
            return new LengthAwarePaginator(
                $this->forPage($page, $perPage),
                $this->count(),
                $perPage,
                $page,
                $options
            );
        };
    }
    
    public function extractEmails()
    {
        return function () {
            return $this->pluck('email')->filter()->unique();
        };
    }
}

Registering it is cleaner:

Collection::mixin(new \App\Macros\CollectionMixins());

Solving the IDE Autocomplete Problem

The biggest downside of macros is that PHPStorm/VS Code doesn't know they exist. You have two solutions:

  1. Laravel IDE Helper: The popular package usually picks them up if you regenerate docs.
  2. Manual PHPDoc: Create a _ide_macros.php file in your root (not loaded by autoloader).
// _ide_macros.php

namespace Illuminate\Support {
    /**
     * @method self standardizeKeys()
     * @method LengthAwarePaginator paginate(int $perPage = 15)
     */
    class Collection {}
}

Unit Testing Macros

Macros are global state, so testing them effectively requires care.

// tests/Unit/CollectionMacroTest.php

test('standardize keys macro works', function () {
    $collection = collect(['My Key' => 1]);
    
    expect($collection->standardizeKeys()->toArray())
        ->toBe(['my_key' => 1]);
});

Because Collection is loaded by the framework boot process, your macros are available in all tests extending TestCase.

Summary

  • Don't clutter controllers with repetition.
  • Do move logic to Macros or Mixins.
  • Do create a dedicated ServiceProvider.
  • Do help your IDE with PHPDocs.

Macros turn your application's specific domain language into first-class citizens of the framework.

Comments