laravel/framework: Fix reference cycle from Mailable headers() and priority() callbacks
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 themstaticchanges 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
Headersandpriority()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 theheaders()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.