Building Expressive Custom Collection Macros in Laravel
Laravel collections have more than one hundred methods for performing operations on arrays, but in practice, large projects tend to have dozens of similar collection chains. You may notice that you write the same collection reductions over and over in different controllers: using filter(), map(), and values() to calculate a discount on an order, normalize a phone number, or find active users.
Writing the same closure inline for each such collection is not convenient: this leads to less readable code and increases the amount of code that must be changed if the algorithm needs to be corrected. Fortunately, Laravel provides two ways to simplify the use of collection reductions — Higher-Order Messaging and Collection Macros.
In this article, we’ll take a closer look at Higher-Order Messaging, study an example of using collection macros, and examine how they can be registered in Laravel to appear in code completion.
Higher-Order Messaging (HOM): Writing Cleaner Closures
Higher-Order Messaging allows you to access methods and properties on collection items as though you were calling them on the collection itself, without having to define closure callbacks.
Say we have a collection of User models, and we want to access an attribute, call a method or sum a property
// Traditional closure syntax
$activeUsers = $users->filter(function ($user) {
return $user->isActive();
});
// Using Higher-Order Messaging
$activeUsers = $users->filter->isActive();Laravel achieves this by way of an internal proxy class called HigherOrderCollectionProxy. When you call a collection method as a property (like $users->filter, $users->map, or $users->each), Laravel interceps the __get magic call, returns the proxy instance, then routes whatever method or property you call next down to each individual item in the collection
Common Higher-Order Messaging Examples
// 1. Invoking a method on each item with map
$orderTotals = $orders->map->calculateTotal();
// 2. Extracting object properties with map
$emails = $users->map->email;
// 3. Executing an action on each model with each
$invoices->each->sendToCustomer();
// 4. Checking conditions with contains
$hasVip = $users->contains->isVip();
// 5. Summing attributes directly
$totalRevenue = $orders->sum->total_amount;While Higher-Order Messaging is convenient for simple transformations, it is not always possible to perform complex multi-step operations or transformations that require domain specific knowledge over the underlying collection.
This is where macros come into play.
Extending Collections with Collection::macro()
Laravel's Collection uses the Macroable trait which allows you to extend the existing functionality of the Collection class.
The macro method lets you append new methods to a class at run-time without needing to modify the actual class code. You can even pass parameters to your macro, since the closure will receive them as arguments:
Inside the closure, $this still refers to the current instance of Collection
Basic Macro Example: Calculating a Trimmed Mean
Suppose your application aggregates review scores or pricing data, and you frequently need to calculate a trimmed mean (dropping the lowest and highest values to remove outliers):
use Illuminate\Support\Collection;
Collection::macro('trimmedMean', function (int $trimCount = 1): float {
// $this refers to the current Collection instance
if ($this->count() <= ($trimCount * 2)) {
return (float) ($this->avg() ?? 0.0);
}
return (float) $this->sort()
->values()
->slice($trimCount, $this->count() - ($trimCount * 2))
->avg();
});Once registered, you can call trimmedMean() on any collection just like a native Laravel method:
$ratings = collect([1, 5, 8, 9, 10, 10, 100]); // Notice the extreme outlier 100
echo $ratings->trimmedMean(1); // Drops 1 and 100, returns average of [5, 8, 9, 10, 10]Real-World Practical Example: Business Domain Macros
Let's build a set of macros for an e-commerce platform that processes order line items and financial reporting.
Step 1: Organizing Macros into a Dedicated Service Provider
Avoid dumping multiple macro definitions directly inside AppServiceProvider::boot(). Instead, create a dedicated CollectionServiceProvider:
php artisan make:provider CollectionServiceProviderRegister the macros inside app/Providers/CollectionServiceProvider.php:
namespace App\Providers;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
class CollectionServiceProvider extends ServiceProvider
{
public function boot(): void
{
/**
* Extract and normalize mobile phone numbers.
*/
Collection::macro('toE164PhoneNumbers', function (?string $key = null): Collection {
/** @var Collection $this */
return $this->map(function ($item) use ($key) {
$raw = $key ? data_get($item, $key) : $item;
if (! is_string($raw)) {
return null;
}
// Strip spaces, dashes, and parentheses
$cleaned = preg_replace('/[^0-9+]/', '', $raw);
// Prepend country code if missing
if (str_starts_with($cleaned, '0')) {
$cleaned = '+91' . substr($cleaned, 1);
}
return $cleaned;
})->filter()->values();
});
/**
* Group items into fixed-size batches and pad the final batch if uneven.
*/
Collection::macro('chunkWithPadding', function (int $size, mixed $paddingValue = null): Collection {
/** @var Collection $this */
return $this->chunk($size)->map(function (Collection $chunk) use ($size, $paddingValue) {
return $chunk->pad($size, $paddingValue);
});
});
}
}Register CollectionServiceProvider in bootstrap/providers.php (or config/app.php on older Laravel versions).
Step 2: Using the Domain Macros in Controllers
Now your controllers and services can run domain transformations with clean method chaining:
namespace App\Http\Controllers;
use App\Models\Customer;
use Illuminate\Http\JsonResponse;
class SmsCampaignController extends Controller
{
public function send(CustomerBulkRequest $request): JsonResponse
{
$customers = Customer::where('accepts_marketing', true)->get();
// Use custom macro with Higher-Order Messaging inside the pipeline
$recipients = $customers
->toE164PhoneNumbers('phone')
->unique();
// Process in padded batches of 50 for external SMS gateway API
$batches = $recipients->chunkWithPadding(50, null);
// ... dispatch to SMS service
return response()->json(['total_batches' => $batches->count()]);
}
}Handling LazyCollection Macros
Illuminate\Support\Collection and Illuminate\Support\LazyCollection are separate classes. If you define a macro on Collection::macro(), that method will not be available on LazyCollections by default.
If you need your macro to support both eager and lazy generator collections, register it on both classes:
use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;
$filterFalsy = function () {
return $this->filter(fn ($item) => ! empty($item));
};
Collection::macro('filterFalsy', $filterFalsy);
LazyCollection::macro('filterFalsy', $filterFalsy);Setting Up IDE Autocompletion for Macros
Because macros are applied at run-time, your IDE (PhpStorm, VS Code with Intelephense) will show an error saying that the method is not found in Collection.
To fix that and have proper autocompletion and no false positive errors in your IDE, you need to create a type hint stub for macros in your project root, for example _ide_macros.php:
namespace Illuminate\Support {
/**
* @method float trimmedMean(int $trimCount = 1)
* @method \Illuminate\Support\Collection toE164PhoneNumbers(string|null $key = null)
* @method \Illuminate\Support\Collection chunkWithPadding(int $size, mixed $paddingValue = null)
*/
class Collection {}
}Your IDE will index this file automatically. Whenever you type $collection->toE164PhoneNumbers(), you get full autocompletion, parameter hints, and return type inspection.
Higher-Order Messaging vs. Collection Macros
| Feature | Higher-Order Messaging (HOM) | Collection Macros |
|---|---|---|
| Scope | Per-item method or property invocation. | Whole-collection transformation or calculation. |
| Registration | Built-in (no registration required). | Requires Collection::macro() in a service provider. |
| Syntax | $users->map->getName() |
$users->toE164PhoneNumbers() |
| Custom Arguments | No (cannot pass arguments to proxied calls). | Yes (accepts any number of custom arguments). |
Important Things to Remember
- Return types: if your macro returns $this or a new collection, make sure that it supports continuation of the method chaining like ->values() or ->toArray().
- Eloquent collections use Illuminate\Database\Eloquent\Collection which extends Illuminate\Support\Collection. This means that any macro you define on the base class will be available for eloquent collections as well.
- Do not define macros with the same name as any existing or future helpers for Laravel's collection classes. The Illuminate\Support\Collection documentation lists some methods which are available on eloquent collections but not standard PHP collections, so make sure to avoid helpers like groupBy, flatten, and sole.
Conclusion
Using Higher-Order Messaging and custom Collection macros you can define expressive collection pipeline styles. Higher-Order Messaging allows you to avoid writing single line closures, while custom macros let you reuse common domain calculations as methods throughout your Laravel application.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
.webp)