skip to content

In Laravel, how do you add a custom method to every collection with Collection::macro(), and where should the macro be registered?

level: middleimportance: nice to knowfreq 25%

answer

  1. Macroable trait
  2. AppServiceProvider boot()
  3. $this is the collection instance
  4. inherited by Eloquent collections
  5. LazyCollection keeps its own macros

basics

~20 s

Call Collection::macro('name', fn) with a closure in a service provider's boot() method; the Macroable trait binds the closure to the collection, so $this inside it is the instance and the method works on every Collection, including Eloquent ones.

solid answer

~40 s

`Illuminate\Support\Collection` uses the `Macroable` trait, so `Collection::macro('averageBy', function (string $field) { return $this->avg($field); })` stores the closure in a static `$macros` array. When you call a method that does not exist, `__call` finds the macro, binds the closure to the current instance with `bindTo($this, static::class)` and runs it, so `$this` is the collection. The docs recommend registering macros in a service provider's `boot()` method, typically `AppServiceProvider`. Eloquent's collection extends the base class and sees the same macros; `LazyCollection` has its own `Macroable` and needs its own registration. `mixin()` registers every method of a class at once, and an unknown name throws `BadMethodCallException`.

code

php · 15 lines
php
<?php

use Illuminate\Support\Collection;
use Illuminate\Support\LazyCollection;

// In AppServiceProvider::boot()
Collection::macro('topBy', function (string $field, int $n = 3) {
    return $this->sortByDesc($field)->take($n)->values();
});

collect($answers)->topBy('score');          // works
SurveyAnswer::all()->topBy('score');        // works: Eloquent Collection extends Collection

LazyCollection::make($rows)->topBy('score'); // BadMethodCallException until
// LazyCollection::macro('topBy', ...) is registered separately

go deeper

for a junior

Know that Collection::macro() adds a method to all collections and that the docs put it in a service provider's boot() method.

for a middle

Explain Macroable's __call dispatch, the closure binding that makes $this the collection, and which subclasses share the macro list.

for a senior

Judge when a macro is justified versus a custom collection class or a plain function, and keep macros discoverable with docblocks and one registration site.

for a principal

Set a team policy for framework extension points such as macros, balancing convenience against global state, tooling blindness and upgrade risk.

## The mechanism: the Macroable trait `Illuminate\Support\Collection` is declared with `use EnumeratesValues, Macroable, …`. The **`Macroable`** trait adds: - a static **`$macros`** array; - `macro($name, $macro)` — stores a closure (or any callable) under a name; - `mixin($object, $replace = true)` — registers every public and protected method of an object as a macro, each of which must **return a closure**; - `hasMacro($name)` and `flushMacros()`; - **`__call`** and **`__callStatic`** — PHP's fallbacks for methods that do not exist. When you call `$answers->averageBy('score')` and the class has no real `averageBy` method, `__call` looks the name up. If the stored macro is a closure, it is **bound** with `bindTo($this, static::class)`, so inside the closure `$this` is the collection you called it on and protected members such as `$this->items` are reachable. If no macro exists, it throws `BadMethodCallException` with `Method Illuminate\Support\Collection::averageBy does not exist.` ## Registering one The Laravel docs say macros should typically be declared in the **`boot` method of a service provider**. In a Laravel 13 app that is `App\Providers\AppServiceProvider`, listed in `bootstrap/providers.php`: ```php <?php namespace App\Providers; use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { public function boot(): void { Collection::macro('averageBy', function (string $group, string $field) { return $this->groupBy($group) ->map(fn (Collection $rows) => round($rows->avg($field), 1)); }); } } ``` Now `$answers->averageBy('department', 'score')` works anywhere after boot. Why `boot()` and not a random file: 1. every request, Artisan command and queue worker boots providers, so the macro is always present; 2. registration happens once per process, not on every call; 3. it keeps global behaviour in one discoverable place. ## Who sees the macro | Class | Sees a macro registered on `Illuminate\Support\Collection`? | Why | |---|---|---| | `Illuminate\Support\Collection` | yes | it owns the static `$macros` | | `Illuminate\Database\Eloquent\Collection` | yes | it extends the base class and does not redeclare the trait | | `Illuminate\Support\LazyCollection` | **no** | it uses `Macroable` itself, so it has its own `$macros` | To support both eager and lazy pipelines, register the macro on both classes. ## Macros cannot override real methods `__call` only runs for methods that do not exist. A macro named `sum` is stored but never called, because the real `sum()` wins. Pick distinctive names; if you truly need different behaviour for a built-in, use a subclass instead. ## Costs and alternatives - **Invisible to tooling.** IDEs and static analysers do not see macros unless you add `@method` docblocks or generated stubs, so a typo surfaces only at runtime. - **Global state.** A macro affects every collection in the app. In tests, `Collection::flushMacros()` removes them all. - **Subclassing instead.** For behaviour that belongs to one model's results, a custom collection class attached with the `#[CollectedBy(SurveyAnswerCollection::class)]` model attribute keeps the method typed and local. - **A plain function or service.** If only one feature uses the logic, a named function is simpler than extending a framework class for everyone. ## The same pattern across the framework `Macroable` is not special to collections. The same trait sits on `Str`, `Stringable`, `Arr`, the HTTP `Request`, the `Router`, the query `Builder` and the HTTP client's `PendingRequest`, among others. Knowing the trait once explains all of them: - registration is always `SomeClass::macro('name', $closure)` in a provider's `boot()`; - instance calls bind `$this` to the object, static calls (`Str::macro` used as `Str::name()`) bind to the class with no instance; - the macro list is per class that uses the trait, shared with subclasses. That consistency is why interviewers often ask about collection macros as a proxy for "how do you extend a Laravel class you do not own?" ## When interviewers ask it The question checks that you know Laravel's extension mechanism rather than patching vendor code, that `$this` is bound to the instance, that registration belongs in a provider's `boot()`, and that a macro is shared by subclasses but not by `LazyCollection`.

  • What does Collection::mixin() expect from the object you pass it in Laravel?
    An object whose public or protected methods each return a closure. `mixin()` reflects over those methods, invokes each one and registers the returned closure as a macro under the method's name. With `$replace = false`, existing macros of the same name are kept.
  • Can a Laravel collection macro named filter replace the built-in filter()?
    No. Macros are dispatched by `__call`, which PHP only invokes for methods that do not exist on the class. `filter()` is a real method, so it always runs and the macro is never reached. Use a different name, or a subclass that overrides the method.

saying these in an interview costs you the question

  • Edits the vendor Collection class to add a method.
  • Registers macros inside a controller action on every request.
  • Believes $this inside the macro closure is the service provider.
  • Expects a macro on Collection to work on LazyCollection too.
  • Thinks a macro can override a built-in method with the same name.