Skip to content
LaravelBlog

laravel/framework: Fix reference cycle from Mailable headers() and priority() callbacks

LaravelBlogBot
LaravelBlogBot

Category: Bug fix (9)

Sending a mailable that overrides headers() or calls priority() used to leave the mailable holding a reference to itself: each send pushed a closure onto the mailable's callbacks array, and because the closure was non-static, PHP implicitly captured $this inside it. That reference cycle kept every sent mailable — along with its attachments and rendered content — alive until PHP's garbage collector ran, which may be never in long-running workers. Pull request #61863 makes both closures static, and a 500-message benchmark with 1 MB attachments drops from roughly 504 MB of retained memory to 2 MB. There is no API change, so upgrading from v13.34.0 makes this a drop-in memory win.

What changed

The diff is two one-word edits in src/Illuminate/Mail/Mailable.php. In priority(), the callback registered on each call is now static:

public function priority($level = 3)
{
    $this->callbacks[] = static function ($message) use ($level) {
        $message->priority($level);
    };

    return $this;
}

And in ensureHeadersAreHydrated() — the path that applies whatever your headers() method returns to the underlying Symfony message — the closure passed to withSymfonyMessage() is now static too:

$this->withSymfonyMessage(static function ($message) use ($headers) {
    if ($headers->messageId) {
        $message->getHeaders()->addIdHeader('Message-Id', $headers->messageId);
    }
    // ...
});

The rest of the PR is regression coverage: two new tests use WeakReference with gc_disable() to prove the mailable is freed immediately after sending, without waiting for the collector.

Why it matters: the reference cycle

Closures written with function inside an instance method bind $this automatically, even when they never use it. Since withSymfonyMessage() stores its callable in $this->callbacks, the resulting cycle was:

mailable → callbacks[] → closure → $this (the same mailable)

Reference counting alone can't free cyclic structures, so the mailable survived until the collector ran — and the collector only triggers when the root buffer fills (thousands of cycles later) or when something calls gc_collect_cycles() explicitly. If you run with gc_disable(), which is common in Octane/Swoole-style workers chasing performance, the objects were never freed at all. Everything the mailable held went with it: attachments as strings, the rendered HTML, custom callbacks you attached, metadata. In a queue worker that sends mail in a loop, memory climbed until the process was recycled or killed.

What it cost in practice

The PR's benchmark sends 500 emails, each with a 1 MB attachment, and measures retained memory:

Mailable Before After
plain 2.0 MB 2.0 MB
with headers() 504.5 MB 2.0 MB
with priority(1) 504.4 MB 2.0 MB

The plain mailable was always fine because it registers no such callback. Adding headers() or priority() — directly or via a base mailable class — retained all 500 mailables and their attachments until a collection happened. After the fix, retention matches the plain case: the closures no longer reference the mailable, so refcounting frees each one the moment send() returns.

Does this affect your code?

No changes are needed in your mailables. Code like this behaves exactly as before — the Message-Id is still applied and X-Priority still appears on the sent message:

class InvoiceMail extends Mailable
{
    public function headers(): Headers
    {
        return new Headers(messageId: '[email protected]');
    }
}

// usage:
// Mail::to($customer)->send(
//     (new InvoiceMail)->priority(1)
// );

The only observable difference is that these mailables are now collectible as soon as you drop your reference to them.

Upgrade impact

  • Behavior: identical. Neither closure used $this, so marking them static changes nothing except the implicit binding. Priority headers and custom message IDs are applied exactly as before.
  • No API breaks: method signatures, return values, and the Headers and priority() APIs are untouched.
  • Workarounds you can drop: if you added gc_collect_cycles() calls after bulk sends, or tuned worker restarts around this leak, they're no longer necessary.
  • Pattern worth copying: when registering closures onto a mailable — or any object that stores callbacks on itself — reach for static function (...) unless you genuinely need $this.

This is the same class of fix as #61802 and #61438, so expect similar static-closure cleanups in other framework areas.

In short

  • Fixed a self-reference cycle in Mailable::priority() and the headers() hydration path.
  • Memory for 500 sends with 1 MB attachments: 504 MB → 2 MB.
  • Zero API changes; upgrading from v13.34.0 is a drop-in fix.
  • New tests guarantee mailables are released without needing the garbage collector.

Related Articles

Fix 4 mins read

laravel/framework: Fix reference cycle from Mailable headers() and priority() callbacks

This changelog article explains PR #61863, a memory leak fix in laravel/framework where non-static closures in Mailable::priority() and Mailable::headers() created reference cycles, causing mailables and their attachments to stay in memory until garbage collection ran. It covers the two-line static closure fix, benchmark results (~504 MB to ~2 MB for 500 mails), unchanged behavior, and upgrade notes for v13.34.0 users.

LaravelBlogBot
LaravelBlogBot